diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 7fa32f35..2092c480 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -634,10 +634,45 @@ impl Appearance { } } +impl Appearance { + /// Is any fill, stroke or effect (the object's or an item's) hidden? + pub fn has_hidden(&self) -> bool { + let hidden = |fx: &[Effect]| fx.iter().any(|e| !e.visible); + hidden(&self.effects) || self.items.iter().any(|i| !i.visible() || hidden(i.effects())) + } + + /// Make every hidden fill, stroke and effect visible (Show All Hidden Attributes). Returns + /// whether anything was hidden. + pub fn show_all(&mut self) -> bool { + let had = self.has_hidden(); + let show = |fx: &mut Vec| fx.iter_mut().for_each(|e| e.visible = true); + show(&mut self.effects); + for i in &mut self.items { + match i { + AppearanceItem::Fill(f) => f.visible = true, + AppearanceItem::Stroke(s) => s.visible = true, + } + show(i.effects_mut()); + } + had + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn hidden_attributes_show_again() { + let mut a = Appearance::default_art(); + assert!(!a.has_hidden() && !a.show_all()); + a.stroke_mut().unwrap().visible = false; + a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); + assert!(a.has_hidden()); + assert!(a.show_all()); + assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); + } + #[test] fn basic_appearance() { let a = Appearance::default_art(); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 3bb9d83c..2c1bc171 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -2,7 +2,8 @@ //! //! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a //! copy of the document in which every geometry effect — object level, per fill/stroke and -//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows, +//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their +//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows, //! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). use std::sync::Arc; @@ -10,7 +11,9 @@ use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; -use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children}; +use crate::{ + GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape, +}; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { @@ -67,6 +70,15 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if !needs_bake(n) { return None; } + // Type, images, symbol instances and live objects: reshaped through their outlines. + if needs_outline(n) && has_geometry(&n.appearance.effects) { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let m = reshape(n, symbol.as_deref())?; + return Some(bake_node(d, &m).unwrap_or(m)); + } if let Some(result) = pathfinder_children(n, None) { let mut m = n.clone(); m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 96d9e378..d30ada80 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -12,6 +12,8 @@ //! renderer, the exporters and Outline Stroke. //! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. +//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects +//! through their outlines. //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. //! @@ -24,6 +26,7 @@ mod clip; mod distort; mod group; mod raster; +mod reshape; pub mod stroke; mod stylize; mod util; @@ -40,6 +43,7 @@ pub use bake::{bake_document, needs_bake}; pub use clip::clip_outline; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use raster::{RasterFx, outset, raster_effects}; +pub use reshape::{needs_outline, outline_art, outline_text, reshape}; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -336,7 +340,7 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() } -fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { +pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { use util::*; match id { "distort.freeDistort" => distort::free_distort(path, b, p), diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs new file mode 100644 index 00000000..84d902da --- /dev/null +++ b/crates/effects/src/reshape.rs @@ -0,0 +1,182 @@ +//! Object-level effects on objects that aren't paths: type, placed images, symbol instances and +//! live objects (blends, envelopes, meshes, repeats). +//! +//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths +//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect +//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects +//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. + +use std::sync::Arc; + +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; + +use crate::{GeomContext, apply_one, is_geometry, merged_params}; + +/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is +/// neither a path nor a container)? +pub fn needs_outline(n: &Node) -> bool { + matches!( + n.kind, + NodeKind::Text(_) + | NodeKind::Image(_) + | NodeKind::SymbolInstance { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + ) +} + +/// Type as glyph outlines in document space: one path per run with the run's fill and stroke, +/// then (when the object has its own fills/strokes) the whole outline with the object's stack, +/// painted over the characters as the renderer does. +pub fn outline_text(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + let mut all = kurbo::BezPath::new(); + for g in &layout.glyphs { + if let Some(r) = runs.get_mut(g.run) { + r.extend(g.outline.iter()); + } + all.extend(g.outline.iter()); + } + let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); + let mut children: Vec> = runs + .iter() + .zip(&t.runs) + .filter(|(bp, _)| !bp.elements().is_empty()) + .map(|(bp, run)| { + let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] }; + if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 { + ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width))); + } + Arc::new(Node::path(n.id, path(bp), ap)) + }) + .collect(); + if !n.appearance.items.is_empty() && !all.elements().is_empty() { + let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] }; + children.push(Arc::new(Node::path(n.id, path(&all), ap))); + } + Some(Node::group(n.id, children)) +} + +/// `n` as plain art that geometry effects can reshape, without its transparency and object +/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), +/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by +/// the instance's transform. `None` for other kinds (images, paths, containers). +pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + let mut art = match &n.kind { + NodeKind::Text(_) => outline_text(n)?, + NodeKind::SymbolInstance { xf, .. } => { + let mut a = symbol_art?.clone(); + a.transform(*xf, false); + Node::group(n.id, vec![Arc::new(a)]) + } + _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), + _ => return None, + }; + outline_nested_text(&mut art); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + // One element: its pieces never knock each other out. + art.knockout = vectorcraft_doc::Knockout::Off; + art.mask = None; + art.appearance.effects.clear(); + Some(art) +} + +/// Replace type anywhere inside `n` by its outlines. +fn outline_nested_text(n: &mut Node) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if let Some(o) = outline_text(c) { + *c = Arc::new(o); + } else if c.children().is_some() { + outline_nested_text(Arc::make_mut(c)); + } + } +} + +/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`] +/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is +/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` +/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). +pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { + let fx: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if fx.is_empty() || !needs_outline(n) { + return None; + } + let mut art = match &n.kind { + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + let mut clip = Node::path(n.id, frame, Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) }; + Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) + } + _ => outline_art(n, symbol_art)?, + }; + for e in fx { + // Every leaf takes the effect with the whole art's bounds as its reference box. + let Some(bounds) = art.geometric_bounds() else { break }; + let params = merged_params(&e.id, &e.params); + for_each_leaf(&mut art, &mut |leaf| { + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext::of(leaf)); + set_geometry(leaf, out, rule); + }); + } + // The art is a group with `n`'s id (a clip group for an image). + let mut out = art; + out.name = n.name.clone(); + out.opacity = n.opacity; + out.blend = n.blend; + out.isolate = n.isolate; + // One element: its pieces never knock each other out. + out.knockout = vectorcraft_doc::Knockout::Off; + out.mask = n.mask.clone(); + out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + Some(out) +} + +/// Run `f` on every path and compound path under `n` (guides skipped). +fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) { + match &mut n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter_mut() { + for_each_leaf(Arc::make_mut(c), f); + } + } + _ => {} + } +} + +fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + _ => None, + } +} + +/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths). +fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) { + let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. }); + n.kind = if path.subpaths.len() > 1 { + let id = n.id; + let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect(); + NodeKind::Compound { children, rule } + } else { + NodeKind::Path { path, rule, live: None, clipping, guide: false } + }; +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index f623671b..4544c981 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -66,7 +66,15 @@ pub fn specs() -> Vec { has_doc, set_item ), - cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), + cmd!( + "appearance.removeItem", + "Remove Item", + [], + None, + "{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack", + has_selection, + remove_item + ), cmd!( "appearance.addEffect", "Add Effect", @@ -76,7 +84,15 @@ pub fn specs() -> Vec { has_selection, super::effectcmd::apply ), - cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item), + cmd!( + "appearance.duplicateItem", + "Duplicate Item", + ["Window", "Appearance"], + None, + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?} copy fills/strokes with their effects", + has_selection, + duplicate_item + ), cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), cmd!( "appearance.copyFrom", @@ -96,6 +112,15 @@ pub fn specs() -> Vec { has_selection, set_active_item ), + cmd!( + "appearance.showAllHidden", + "Show All Hidden Attributes", + ["Window", "Appearance"], + None, + "{ids?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + has_selection, + show_all_hidden + ), ] } @@ -146,12 +171,17 @@ pub(crate) enum ItemTarget { /// Parse the `item` param of `cmd` (see the module docs). pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result { - match p.get("item") { + item_target_at(s, p, "item", cmd) +} + +/// [`item_target`] read from param `key` (e.g. `fromItem`). +pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { Some(Value::Null) => Ok(ItemTarget::Top), Some(v) => v .as_u64() .map(|i| ItemTarget::Item { index: i as usize, explicit: true }) - .ok_or_else(|| bad(cmd, "`item` must be an appearance item index (paint order) or null")), + .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top), None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), } @@ -385,45 +415,92 @@ fn set_item(s: &mut Session, p: &Value) -> Result { ok() } +/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and +/// without repeats. +fn item_indices(p: &Value, cmd: &str) -> Result> { + let mut v = match p.get("indices") { + Some(a) => a + .as_array() + .and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::>>()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?, + None => vec![index_param(p, "index", cmd)?], + }; + v.sort_unstable(); + v.dedup(); + Ok(v) +} + fn remove_item(s: &mut Session, p: &Value) -> Result { - let idx = index_param(p, "index", "appearance.removeItem")?; + let idx = item_indices(p, "appearance.removeItem")?; let ids = appearance_targets(s, p)?; s.edit("Remove Item", |d, _| { for id in &ids { - if let Some(n) = d.node_mut(*id) - && idx < n.appearance.items.len() - { - n.appearance.items.remove(idx); + if let Some(n) = d.node_mut(*id) { + let items = &mut n.appearance.items; + // From the top down, so the lower indices stay valid. + for &i in idx.iter().rev() { + if i < items.len() { + items.remove(i); + } + } } } Ok(()) })?; - s.remap_appearance_item(&ids, |a| match a.cmp(&idx) { - std::cmp::Ordering::Less => Some(a), - std::cmp::Ordering::Equal => None, - std::cmp::Ordering::Greater => Some(a - 1), - }); + s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() } fn duplicate_item(s: &mut Session, p: &Value) -> Result { - let idx = index_param(p, "index", "appearance.duplicateItem")?; + const C: &str = "appearance.duplicateItem"; + let idx = item_indices(p, C)?; + let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?; + if to.is_some() && idx.len() > 1 { + return Err(bad(C, "`to` takes a single `index`")); + } let ids = appearance_targets(s, p)?; + // Where the first object's copy landed (the active item lives in the first object's stack). + let mut first_at = None; s.edit("Duplicate Item", |d, _| { let mut any = false; for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; - if let Some(item) = n.appearance.items.get(idx).cloned() { - n.appearance.items.insert(idx + 1, item); - any = true; + let items = &mut n.appearance.items; + // From the top down, so the lower indices stay valid. + for &i in idx.iter().rev() { + if let Some(item) = items.get(i).cloned() { + let at = to.map_or(i + 1, |t| t.min(items.len())); + first_at.get_or_insert(at); + items.insert(at, item); + any = true; + } } } - if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } + if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) } })?; - s.remap_appearance_item(&ids, |a| Some(if a > idx { a + 1 } else { a })); + // Rows at or above each copy move up by one. + s.remap_appearance_item(&ids, |a| { + Some(match to { + Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)), + None => a + idx.iter().filter(|i| **i < a).count(), + }) + }); ok() } +fn show_all_hidden(s: &mut Session, p: &Value) -> Result { + let roots = appearance_targets(s, p)?; + let ids = s.edit("Show All Hidden Attributes", |d, _| { + let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + if shown.is_empty() { + return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); + } + Ok(shown) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; let from = index_param(p, "from", C)?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 85bf5eeb..4f59a850 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, appearance_targets, index_param, item_target}; +use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; use super::*; pub fn specs() -> Vec { @@ -69,6 +69,15 @@ pub fn specs() -> Vec { has_selection, duplicate ), + cmd!( + "effect.move", + "Move Effect", + [], + None, + "{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + has_selection, + move_effect + ), ] } @@ -175,6 +184,38 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +fn move_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.move"; + let from = index_param(p, "from", C)?; + let to = index_param(p, "to", C)?; + let src = item_target_at(s, p, "fromItem", C)?; + let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let roots = appearance_targets(s, p)?; + // Where the effect landed in the first object. + let mut landed = None; + let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + let (si, di) = (src.effects_item(&n.appearance, C)?, dst.effects_item(&n.appearance, C)?); + let Some(fx) = n.appearance.effects_mut(si).filter(|fx| from < fx.len()) else { continue }; + let e = if copy { fx[from].clone() } else { fx.remove(from) }; + let Some(fx) = n.appearance.effects_mut(di) else { continue }; + let at = to.min(fx.len()); + fx.insert(at, e); + landed.get_or_insert((at, di)); + done.push(*id); + } + if done.is_empty() { + return Err(EngineError::Other(format!("{C}: no effect at index {from}"))); + } + Ok(done) + })?; + let (index, item) = landed.unzip(); + Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() })) +} + fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8cdb54b8..2e044ada 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -754,6 +754,8 @@ mod tests_distort; #[cfg(test)] mod tests_draw2; #[cfg(test)] +mod tests_effectedit; +#[cfg(test)] mod tests_file; #[cfg(test)] mod tests_gradient; diff --git a/crates/engine/src/tests_effectedit.rs b/crates/engine/src/tests_effectedit.rs new file mode 100644 index 00000000..04242589 --- /dev/null +++ b/crates/engine/src/tests_effectedit.rs @@ -0,0 +1,121 @@ +//! Appearance editing depth: moving and copying effects between the object and its fills and +//! strokes, duplicating and removing several items, Show All Hidden Attributes. + +use serde_json::{Value, json}; +use vectorcraft_doc::Node; + +use super::*; + +/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. +fn session_with_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// Effect ids of the object (`None`) or of item `i`. +fn fx(s: &Session, id: NodeId, item: Option) -> Vec { + node(s, id).appearance.effects_at(item).unwrap().iter().map(|e| e.id.clone()).collect() +} + +#[test] +fn effects_reorder_within_a_list_and_undo_in_one_step() { + let (mut s, id) = session_with_rect(); + for e in ["distort.twist", "distort.roughen", "stylize.dropShadow"] { + run(&mut s, "effect.apply", json!({ "effect": e })); + } + let r = run(&mut s, "effect.move", json!({"from": 0, "to": 2})); + assert_eq!((r["index"].clone(), r["item"].clone()), (json!(2), Value::Null)); + assert_eq!(fx(&s, id, None), ["distort.roughen", "stylize.dropShadow", "distort.twist"]); + // Past the end lands last. + run(&mut s, "effect.move", json!({"from": 0, "to": 9})); + assert_eq!(fx(&s, id, None), ["stylize.dropShadow", "distort.twist", "distort.roughen"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fx(&s, id, None), ["distort.twist", "distort.roughen", "stylize.dropShadow"]); + assert!(s.execute("effect.move", &json!({"from": 3, "to": 0})).is_err()); +} + +#[test] +fn effects_move_and_copy_between_the_object_and_its_items() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "item": 1})); + // Into the stroke, above its own effect. + let r = run(&mut s, "effect.move", json!({"from": 0, "fromItem": null, "to": 1, "toItem": 1})); + assert_eq!((r["index"].clone(), r["item"].clone()), (json!(1), json!(1))); + assert!(fx(&s, id, None).is_empty()); + assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]); + // Out of the stroke onto the fill, as a copy (Alt-drag): the stroke keeps it. + run(&mut s, "effect.move", json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 0, "copy": true})); + assert_eq!(fx(&s, id, Some(0)), ["path.offsetPath"]); + assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]); + // Back out to the object, one undo step each. + run(&mut s, "effect.move", json!({"from": 1, "fromItem": 1, "to": 0, "toItem": null})); + assert_eq!(fx(&s, id, None), ["distort.twist"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fx(&s, id, None).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fx(&s, id, Some(0)).is_empty()); + // A missing item is an error, not a silent no-op. + assert!(s.execute("effect.move", &json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 7})).is_err()); +} + +#[test] +fn duplicate_selected_works_for_effects() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 0})); + run(&mut s, "effect.duplicate", json!({"index": 0, "item": 0})); + assert_eq!(fx(&s, id, Some(0)), ["distort.roughen", "distort.roughen"]); +} + +#[test] +fn items_duplicate_to_a_position_and_several_remove_at_once() { + let (mut s, id) = session_with_rect(); + run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); + // Alt-dragging the top fill to the bottom copies it there. + run(&mut s, "appearance.duplicateItem", json!({"index": 2, "to": 0})); + let kinds = |s: &Session| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::>(); + assert_eq!(kinds(&s), ["fill", "fill", "stroke", "fill"]); + assert_eq!(node(&s, id).appearance.items[0].paint(), node(&s, id).appearance.items[3].paint()); + // Duplicating several copies each above itself. + run(&mut s, "appearance.duplicateItem", json!({"indices": [0, 2]})); + assert_eq!(kinds(&s), ["fill", "fill", "fill", "stroke", "stroke", "fill"]); + assert!(s.execute("appearance.duplicateItem", &json!({"indices": [0, 1], "to": 3})).is_err()); + // Removing several in one step; the active item follows. + run(&mut s, "appearance.setActiveItem", json!({"index": 5})); + run(&mut s, "appearance.removeItem", json!({"indices": [4, 0, 1]})); + assert_eq!(kinds(&s), ["fill", "stroke", "fill"]); + assert_eq!(s.appearance_item(), Some(2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.items.len(), 6); +} + +#[test] +fn show_all_hidden_attributes() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + assert!(s.execute("appearance.showAllHidden", &json!({})).is_err(), "nothing hidden yet"); + run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false})); + run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": null})); + run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": 1})); + assert!(node(&s, id).appearance.has_hidden()); + let r = run(&mut s, "appearance.showAllHidden", json!({})); + assert_eq!(r["ids"], json!([id.0])); + let n = node(&s, id); + assert!(!n.appearance.has_hidden()); + assert!(n.appearance.effects[0].visible && n.appearance.items[1].effects()[0].visible && n.appearance.items[0].visible()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.has_hidden()); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e1a72e89..f8046d51 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -251,6 +251,13 @@ impl Exporter<'_> { SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into() } + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. + fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) { + if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); + } + } + fn warn(&mut self, w: impl Into) { let w = w.into(); if !self.warnings.contains(&w) { @@ -425,9 +432,7 @@ impl Exporter<'_> { None if !n.is_container() => return, _ => {} } - if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); - } + self.warn_raster(&n.appearance.effects); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); // Whether this container's children knock each other out (a knockout group is isolated). let knockout = n.knocks_out(self.knockout); @@ -509,9 +514,7 @@ impl Exporter<'_> { if !fl.visible || fl.paint.is_none() { continue; } - if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); - } + self.warn_raster(&fl.effects); // Pattern fills: the tile instances covering the shape, clipped to it. let doc = self.doc; if let Paint::Pattern { pattern, xf } = &fl.paint @@ -546,6 +549,7 @@ impl Exporter<'_> { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } + self.warn_raster(&st.effects); self.stroke(s, bp, &path, r, st, page); } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e0a18467..fcf997bf 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -108,6 +108,8 @@ pub enum PdfError { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_fx; +#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokeout; diff --git a/crates/pdf/src/tests_fx.rs b/crates/pdf/src/tests_fx.rs new file mode 100644 index 00000000..cd73c01b --- /dev/null +++ b/crates/pdf/src/tests_fx.rs @@ -0,0 +1,29 @@ +//! Raster effects in PDF export: not written yet, so a fill's, a stroke's or the object's own +//! raster effect is reported. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +fn warnings(item: Option) -> Vec { + let mut d = Document::new(100.0, 100.0); + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.effects_mut(item).unwrap().push(Effect { id: "stylize.outerGlow".into(), params: Default::default(), visible: true }); + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + export_with_report(&d, &PdfOptions::default()).unwrap().warnings +} + +#[test] +fn raster_effects_warn_on_fills_strokes_and_objects() { + for item in [None, Some(0), Some(1)] { + assert!(warnings(item).iter().any(|w| w.starts_with("raster effects")), "{item:?}"); + } +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 273a445e..7ecd9c73 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -12,10 +12,16 @@ //! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward; //! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped //! to the shape and painted above it. +//! +//! The same applies to one fill or stroke's own raster effects (around that item alone) and to +//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is +//! drawn offscreen in the filter layers, reshaped first by their geometry effects. + +use std::sync::Arc; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; use vectorcraft_effects::{self as effects, GeomContext, RasterFx}; -use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -38,6 +44,30 @@ pub(crate) fn has_fx(n: &Node) -> bool { }) } +/// Does `n` carry visible object-level effects that only apply through its art (type, images, +/// symbol instances, live objects; see [`Renderer::draw_object_fx`])? +pub(crate) fn has_object_fx(n: &Node) -> bool { + effects::needs_outline(n) && visible(&n.appearance.effects) +} + +/// What [`Renderer::raster_fx`] paints effects around. +struct Content<'a> { + /// The object the effects belong to (cached shadows are checked against it). + node: &'a Node, + /// First shadow-cache slot of these effects (`None`: don't cache). + cache: Option, + /// Document-space bounds of the painted content. + reach: Rect, + /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and + /// Inner Glow is skipped). + outline: Option<(&'a BezPath, FillRule)>, +} + +/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). +fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { + Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } +} + fn node_bezpath(n: &Node) -> Option { match &n.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), @@ -101,6 +131,12 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) } _ if has_fx(n) => visual_bounds(n), + // The reshaped art (a symbol's art needs the document: its instance box stands in). + _ if has_object_fx(n) => { + let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?; + let o = effects::outset(&n.appearance.effects); + Some(b.inflate(o, o)) + } _ => n.visual_bounds(), } } @@ -204,7 +240,7 @@ fn blur_alpha(a: &mut Vec, w: usize, h: usize, sigma: f64) { } impl Renderer { - /// Drop shadow / outer glow `fx` (index `i` in the object's raster effects) from a cached raster: + /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster: /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when /// the effect isn't a shadow/glow or the raster would be too large (caller falls back). @@ -216,10 +252,8 @@ impl Renderer { n: &Node, i: usize, fx: &RasterFx, - g: &BezPath, - rule: FillRule, - gctx: &GeomContext, reach: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> bool { let (mode, opacity, dx, dy, blur, color) = match fx { RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), @@ -239,7 +273,7 @@ impl Renderer { } None => { let spread = blur.max(0.0) * 1.5 / f.px + 2.0; - let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); + let reach = reach + Vec2::new(dx, dy); let r = f.view.transform_rect_bbox(reach).inflate(spread, spread); let (x0, y0) = (r.x0.floor(), r.y0.floor()); let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); @@ -249,8 +283,8 @@ impl Renderer { let (w, h) = (w as u16, h as u16); // Silhouette (the object's painted alpha), offset by the shadow distance. let mut off = crate::single_threaded_context(w, h); - let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view * Affine::translate((dx, dy)), ..*f }; - self.paint_items(&mut off, &shifted, n, g, rule, gctx); + let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + paint(self, &mut off, &shifted(&crop, dx, dy)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); @@ -298,33 +332,120 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - // Below the object: shadows and outer glows. + let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx)); + ctx.set_transform(Affine::IDENTITY); + } + + /// Draw type, an image, a symbol instance or a live object with object-level effects + /// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry + /// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths, + /// the art standing for the object's silhouette. + pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { + let opacity = self.opacity_of(a); + let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate); + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None); + } + let art = self.fx_art(f.doc, a, cache); + let rfx = effects::raster_effects(&a.appearance.effects); + match cull_bounds(&art) { + Some(reach) if !f.opts.outline && !rfx.is_empty() => { + // Feather and Inner Glow clip to the art's outline. + let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. })); + let region = if clipped { self.clip_of(&art) } else { None }; + // A symbol's art can change while the instance doesn't: no cached shadows. + let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); + let content = Content { node: a, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true)); + ctx.set_transform(Affine::IDENTITY); + } + _ => self.draw_node(ctx, f, &art, true), + } + if layered { + ctx.pop_layer(); + } + self.stats.drawn += 1; + } + + /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): + /// reshaped by its geometry effects, else type and images as themselves and the others as + /// their evaluated art; without the object's transparency and effects. + fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { + let key = Arc::as_ptr(a) as usize; + if cache + && let Some((node, art)) = self.fx_arts.get(&key) + && Arc::ptr_eq(node, a) + { + return art.clone(); + } + let symbol = match &a.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) + } + _ => None, + }; + let art = match effects::reshape(a, symbol.as_ref()) { + Some(r) => r, + None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), + None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), + }; + let art = Arc::new(Node { + opacity: 1.0, + blend: Default::default(), + isolate: false, + // One element: its pieces never knock each other out. + knockout: vectorcraft_doc::Knockout::Off, + mask: None, + appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, + ..art + }); + if cache { + if self.fx_arts.len() > 1024 { + self.fx_arts.clear(); + } + self.fx_arts.insert(key, (a.clone(), art.clone())); + } + art + } + + /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows + /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). + /// `paint` draws the content in the frame it is given. + fn raster_fx( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + content: &Content, + rfx: &[RasterFx], + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let reach = content.reach; + // Below the content: shadows and outer glows. for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { - if self.draw_shadow_cached(ctx, f, n, i, fx, &g, rule, &gctx, reach) { + if let Some(slot) = content.cache + && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint) + { continue; } - // The offset is applied to the geometry rather than in the filter: vello_cpu drops - // layer content that lies entirely outside the viewport before filtering. - let (mode, opacity, dx, dy, blur, filter) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { - (*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) - } - RasterFx::OuterGlow { mode, opacity, blur, color } => { - (*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) - } + let (mode, opacity, dx, dy, blur, color) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), _ => continue, }; - let mut gs = g.clone(); - gs.apply_affine(Affine::translate((dx, dy))); - let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); + let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color)); + // The offset moves the content rather than the filter: vello_cpu drops layer content + // that lies entirely outside the viewport before filtering. + let reach = reach + Vec2::new(dx, dy); self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); - r.paint_items(c, fr, n, &gs, rule, &gctx); + paint(r, c, &shifted(fr, dx, dy)); c.pop_layer(); }); } - // The object itself (blurred / feathered). + // The content itself (blurred / feathered). let blur: f64 = rfx .iter() .map(|fx| match fx { @@ -335,14 +456,17 @@ impl Renderer { if blur > 0.0 { self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { let mut layers = 0; - for fx in &rfx { + for fx in rfx { match fx { RasterFx::Feather { radius } if *radius > 0.0 => { c.set_transform(fr.view); - c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(&g); + if let Some((g, rule)) = content.outline { + c.set_fill_rule(fill_rule(rule)); + c.push_clip_layer(g); + layers += 1; + } c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 2; + layers += 1; } RasterFx::GaussianBlur { radius } if *radius > 0.0 => { c.set_transform(fr.view); @@ -352,33 +476,34 @@ impl Renderer { _ => {} } } - r.paint_items(c, fr, n, &g, rule, &gctx); + paint(r, c, fr); for _ in 0..layers { c.pop_layer(); } }); } else { - self.paint_items(ctx, f, n, &g, rule, &gctx); + paint(self, ctx, f); } - // Above the object: inner glows, clipped to the shape. - for fx in &rfx { + // Above the content: inner glows, clipped to its outline. + let Some((g, rule)) = content.outline else { return }; + for fx in rfx { let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color)); self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { // The blend layer goes outside the clip: a clip layer is isolated, so a blend - // inside it would mix with nothing instead of the object below. + // inside it would mix with nothing instead of the content below. if let Some((m, o)) = comp { c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } c.set_transform(fr.view); c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(&g); + c.push_clip_layer(g); c.push_layer(None, None, None, None, Some(filter)); c.set_paint(peniko::Color::BLACK); if *center { c.set_fill_rule(fill_rule(rule)); - c.fill_path(&g); + c.fill_path(g); } else { // Everything outside the shape, so the glow bleeds in from the edges. let pad = blur * 2.0 + 4.0 * fr.px; @@ -394,7 +519,6 @@ impl Renderer { } }); } - ctx.set_transform(Affine::IDENTITY); } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` @@ -443,41 +567,66 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); } - /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects. + /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and + /// painting per-item raster effects around that item alone. fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) { let bounds = g.bounding_box(); - for item in &n.appearance.items { + for (j, item) in n.appearance.items.iter().enumerate() { + if !item.visible() || item.paint().is_none() { + continue; + } let ig = apply(item_effects(item), g, &gctx.item(item)); let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; - match item { - AppearanceItem::Fill(fl) => { - if !fl.visible || fl.paint.is_none() { - continue; - } - let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); - } - ctx.set_transform(f.view); - if paint::set_paint(ctx, &fl.paint, ib, f.doc) { - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(&ig); - } - if layered { - ctx.pop_layer(); - } - } + let rfx = effects::raster_effects(item_effects(item)); + if rfx.is_empty() { + self.paint_item(ctx, f, n, item, &ig, rule, ib); + continue; + } + // The item's silhouette: its fill area, or the outline of its stroke. + let outline = match item { + AppearanceItem::Fill(_) => ig.clone(), AppearanceItem::Stroke(st) => { - if !st.visible || st.paint.is_none() || st.width <= 0.0 { - continue; - } - // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). - if st.brush.is_some() && self.draw_brush(ctx, f, n, &ig, st) { - continue; - } - self.draw_stroke(ctx, f, &ig, rule, st, ib); + let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig)); + effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25) } + }; + let outset = n.appearance.outset(); + let orule = if item.is_fill() { rule } else { FillRule::NonZero }; + // Each item's shadows get their own cache slots. + let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, &ig, rule, ib)); + ctx.set_transform(Affine::IDENTITY); + } + } + + /// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`). + #[allow(clippy::too_many_arguments)] + fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) { + match item { + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, ib, f.doc) { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(ig); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) => { + if st.width <= 0.0 { + return; + } + // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). + if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { + return; + } + self.draw_stroke(ctx, f, ig, rule, st, ib); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 24a95fe4..bf056500 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -235,6 +235,8 @@ pub struct Renderer { /// Address of the knockout-group element being drawn as its knockout shape: at full object /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none. shape_of: usize, + /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). + fx_arts: PtrMap, Arc)>, } #[derive(Clone, Copy, Debug, Default)] @@ -284,6 +286,7 @@ impl Renderer { live: live::LiveCache::default(), knockout: false, shape_of: 0, + fx_arts: PtrMap::default(), } } @@ -557,6 +560,9 @@ impl Renderer { self.stats.drawn += 1; return; } + if fx::has_object_fx(a) { + return self.draw_object_fx(ctx, f, a, true); + } if let NodeKind::Text(t) = &a.kind && self.opacity_of(a) >= 1.0 && a.blend == vectorcraft_color::BlendMode::Normal @@ -588,6 +594,9 @@ impl Renderer { } else if !n.is_container() { return; } + if fx::has_object_fx(n) { + return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); + } let outline = f.opts.outline; let mut layers = 0; let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; @@ -1084,3 +1093,5 @@ mod tests; mod tests_clip; #[cfg(test)] mod tests_knockout; +#[cfg(test)] +mod tests_objectfx; diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs new file mode 100644 index 00000000..eae7df07 --- /dev/null +++ b/crates/render/src/tests_objectfx.rs @@ -0,0 +1,152 @@ +//! Effects on every object kind: raster effects on type, images and symbol instances through an +//! offscreen layer of their art, geometry effects through their outlines, and one fill or +//! stroke's own raster effects. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100×100 document with `n` (its id allocated) on the first layer. +fn doc_with(mut d: Document, mut n: Node) -> Document { + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn render_threads(d: &Document, threads: u16) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn render(d: &Document) -> Rendered { + render_threads(d, 0) +} + +fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 +} + +const WHITE: u32 = 765; + +/// A 30×30 red image placed at (20, 20). +fn image_doc(effects: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }), + ); + n.appearance.effects = effects; + doc_with(d, n) +} + +/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin. +fn symbol_doc(effects: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) }); + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) }); + n.appearance.effects = effects; + doc_with(d, n) +} + +/// Black 40 pt "IIII" with its baseline at (5, 60). +fn text_doc(effects: Vec) -> Document { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style)))); + n.appearance.effects = effects; + doc_with(Document::new(100.0, 100.0), n) +} + +/// Columns (x) holding any dark pixel. +fn inked_columns(img: &Rendered) -> Vec { + (0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect() +} + +#[test] +fn a_shadow_on_an_image_paints_offset_pixels() { + let plain = render(&image_doc(vec![])); + assert_eq!(lum(plain.pixel(55, 55)), WHITE); + let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]); + let img = render(&d); + assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55)); + assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top"); + assert_eq!(lum(img.pixel(15, 15)), WHITE); + // The multithreaded pipeline filters offscreen and matches. + let mt = render_threads(&d, 3); + let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "max channel difference {worst}"); +} + +#[test] +fn blur_on_a_symbol_instance_softens_its_edges() { + let plain = render(&symbol_doc(vec![])); + assert_eq!(lum(plain.pixel(38, 50)), WHITE); + assert_eq!(lum(plain.pixel(50, 50)), 0); + let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))])); + let edge = lum(img.pixel(38, 50)); + assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}"); + assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark"); +} + +#[test] +fn geometry_effects_reshape_type_through_its_outlines() { + let plain = inked_columns(&render(&text_doc(vec![]))); + assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}"); + // Transform moves the outlines as one piece. + let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))]))); + assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30)); + // Roughen redraws the glyphs as rough outlines. + let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]); + let rough = render(&d); + let smooth = render(&text_doc(vec![])); + let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count(); + assert!(changed > 100, "{changed} pixels changed"); + // The reshaped art is plain paths painted with the text's fill. + let n = &d.layers[0].children().unwrap()[0]; + let art = vectorcraft_effects::reshape(n, None).unwrap(); + let (mut text, mut black) = (0, 0); + art.walk(&mut |m| { + text += usize::from(matches!(m.kind, NodeKind::Text(_))); + black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK)); + }); + assert!(text == 0 && black > 0, "{art:?}"); +} + +#[test] +fn a_strokes_own_glow_paints_around_that_stroke_only() { + let mut d = Document::new(100.0, 100.0); + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6}))); + d = doc_with(d, n); + let img = render(&d); + let g = img.pixel(27, 50); + assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}"); + assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow"); + let mt = render_threads(&d, 3); + let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "max channel difference {worst}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 5a47e031..27daeeab 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -540,6 +540,7 @@ impl Writer<'_> { let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); let simple = fills <= 1 && strokes <= 1 + && !items.iter().any(|i| has_raster(i.effects())) && items.iter().all(|i| i.blend() == BlendMode::Normal) && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); if simple { @@ -568,6 +569,8 @@ impl Writer<'_> { self.line(&format!("")); self.depth += 1; for (it, plan) in items.into_iter().zip(plans) { + // The item's own raster effects filter that item alone. + let filters = self.open_filters(n, it.effects()); let mut p = Props::new(); match (it, plan) { (AppearanceItem::Fill(f), _) => { @@ -635,6 +638,7 @@ impl Writer<'_> { } (AppearanceItem::Stroke(_), None) => {} } + self.close_filters(filters); } self.depth -= 1; self.line(""); @@ -760,7 +764,7 @@ impl Writer<'_> { if !n.visible { return; } - if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + if has_raster(&n.appearance.effects) { return self.filtered(n); } if let Some(m) = n.mask.as_deref() @@ -860,15 +864,22 @@ impl Writer<'_> { } /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (Illustrator's stacking order). + /// per effect, the first effect innermost (the reference app's stacking order). fn filtered(&mut self, n: &Node) { - use vectorcraft_effects::RasterFx; - let fx = vectorcraft_effects::raster_effects(&n.appearance.effects); let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let opened = self.open_filters(n, &n.appearance.effects); + self.node(&inner); + self.close_filters(opened); + } + + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or + /// stroke's) on `n`, outermost last effect; returns how many to close. + fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { + use vectorcraft_effects::RasterFx; + let fx = vectorcraft_effects::raster_effects(effects); let reach: f64 = fx.iter().map(RasterFx::outset).sum::() + 2.0; let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); - let mut opened = 0; for f in fx.iter().rev() { let fid = self.fresh_id("filter"); let region_attr = match region { @@ -922,9 +933,11 @@ impl Writer<'_> { self.def(1, &format!("{body}")); self.line(&format!("")); self.depth += 1; - opened += 1; } - self.node(&inner); + fx.len() + } + + fn close_filters(&mut self, opened: usize) { for _ in 0..opened { self.depth -= 1; self.line(""); @@ -1130,3 +1143,8 @@ impl Writer<'_> { out } } + +/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? +fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { + effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) +} diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs new file mode 100644 index 00000000..911c2342 --- /dev/null +++ b/crates/svg/tests/effects_export.rs @@ -0,0 +1,58 @@ +//! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's +//! element, and geometry effects on type are baked through its outlines. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export}; + +fn doc_with(mut n: Node) -> Document { + let mut d = Document::new(200.0, 200.0); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn fx(id: &str) -> Effect { + Effect { id: id.into(), params: json!({}), visible: true } +} + +#[test] +fn a_strokes_own_glow_filters_the_stroke_element_only() { + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow")); + let svg = export(&doc_with(n), &ExportOptions::default()); + assert_eq!(svg.matches("").map(|i| open + i).unwrap()]; + assert!(inside.contains("stroke=\"#000000\"") && !inside.contains("fill=\"#ffffff\""), "{inside}"); + assert!(svg[..open].contains("fill=\"#ffffff\""), "{svg}"); + // A hidden effect exports nothing. + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(Effect { visible: false, ..fx("stylize.outerGlow") }); + assert!(!export(&doc_with(n), &ExportOptions::default()).contains(" Option { + let target = hit.top_object(st.isolation); + if target == *source { + return; + } + ("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]})) + } }; if let Err(e) = app.run(cmd, params) { app.status(e); @@ -1155,4 +1163,38 @@ mod tests { drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0)); assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none())); } + + #[test] + fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() { + use vectorcraft_doc::NodeId; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut app, "transparency.set", json!({"opacity": 40})); + let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [a] })); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // The Appearance panel's thumbnail (its drag payload) released over the second rectangle. + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a))); + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![up]); + }; + drop(&mut app, Point::new(250.0, 70.0)); + let doc = &app.session.active().unwrap().doc; + let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap()); + assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity)); + assert_eq!(nb.appearance.effects[0].id, "distort.twist"); + // Dropped on empty canvas or on the source itself: nothing changes. + let undo_len = app.session.active().unwrap().history.undo.len(); + drop(&mut app, Point::new(350.0, 250.0)); + drop(&mut app, Point::new(70.0, 70.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len); + } } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index dbd4e67f..a8333562 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -1,15 +1,115 @@ -//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a -//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is -//! the appearance item the effect goes to (else the Appearance panel's active item applies). +//! Live effect dialogs (Effect menu, Appearance panel): the effect's parameters (`__effect`, headed +//! `__label`) with a live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, +//! when present, is the appearance item the effect goes to (else the Appearance panel's active +//! item applies). With `__index` the dialog edits that applied effect in place +//! (`effect.setParams`) instead of adding one (`effect.apply`). +//! +//! Choosing an effect that the target already carries first asks ([`EXISTS`]) whether to edit it +//! or add another one. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{DialogSpec, form}; use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; +/// The dialog kind asking whether to edit an effect that is already applied or add another. +/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and +/// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds +/// a new one. +pub const EXISTS: &str = "effectExists"; + +pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec { + heading: |d| format!("{} is already applied", d.str("__label")), + body: |_, ui, _| { + ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim)); + false + }, + confirm: confirm_exists, + ok: Some("Edit"), + discard: Some("Add New"), + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// `effect.dialog {effect, index?, item?}`: edit applied effect `index` of `item` (null: the +/// object's effects; omitted: the active item's, else the object's) prefilled with its values, +/// or add `effect`, first asking when that list already has it. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let id = p.get("effect").and_then(Value::as_str).unwrap_or_default(); + let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?; + // The list the effect goes to: `item` as given, else the active item's (the object's without + // one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual. + let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item())); + let applied = target_effects(app, &target); + let mut fields = Map::new(); + fields.insert("__effect".into(), json!(id)); + fields.insert("__label".into(), json!(info.label.trim_end_matches('…'))); + if let Some(item) = p.get("item") { + fields.insert("__item".into(), item.clone()); + } + match p.get("index").and_then(Value::as_u64) { + Some(k) => { + let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?; + if e.id != id { + return Err(format!("effect {k} is `{}`, not `{id}`", e.id)); + } + fields.insert("__item".into(), target); + fields.insert("__index".into(), json!(k)); + fields.extend(params_of(e)); + } + None => { + if let Some(k) = applied.iter().rposition(|e| e.id == id) { + fields.insert("__target".into(), target); + fields.insert("__index".into(), json!(k)); + app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields }); + return Ok(json!({ "pending": EXISTS })); + } + fields.extend(info.defaults.as_object().cloned().unwrap_or_default()); + } + } + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) +} + +/// The effects of the first selected object that `item` (an index or null) addresses. +fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let node = st.selection.objects.first().and_then(|id| st.doc.node(*id)); + node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default() +} + +/// An applied effect's values over its defaults. +fn params_of(e: &vectorcraft_doc::Effect) -> Map { + match vectorcraft_effects::merged_params(&e.id, &e.params) { + Value::Object(m) => m, + _ => Map::new(), + } +} + +/// OK in [`EXISTS`]: the dialog editing the applied effect, or (`discard`) a fresh one. +fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut fields = d.fields.clone(); + fields.remove("discard"); + let target = fields.remove("__target").unwrap_or(Value::Null); + if d.bool("discard") { + fields.remove("__index"); + let defaults = vectorcraft_effects::default_params(&d.str("__effect")); + fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default()); + } else { + let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize; + fields.extend(target_effects(app, &target).get(k).map(params_of).unwrap_or_default()); + fields.insert("__item".into(), target); + } + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let changed = form::param_fields(ui, d); ui.add_space(6.0); @@ -19,32 +119,42 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if pv && (changed || pv_changed || !app.session.in_interaction()) { let label = d.str("__label"); let _ = app.session.begin_interaction(&label); - let _ = app.session.preview("effect.apply", &apply_params(d)); + let (cmd, p) = command(d); + let _ = app.session.preview(cmd, &p); } else if !pv && pv_changed { let _ = app.session.cancel_interaction(); } false } -/// `effect.apply` params for the dialog's effect, values and target item. -fn apply_params(d: &Dialog) -> Value { - let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)}); - if let Some(item) = d.fields.get("__item") { - p["item"] = item.clone(); +/// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the +/// dialog's effect with its values on the target item. +fn command(d: &Dialog) -> (&'static str, Value) { + let params = form::params(d); + let item = d.fields.get("__item").cloned(); + if let Some(index) = d.fields.get("__index") { + return ("effect.setParams", json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params})); } - p + let mut p = json!({"effect": d.str("__effect"), "params": params}); + if let Some(item) = item { + p["item"] = item; + } + ("effect.apply", p) } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let p = apply_params(d); + let (cmd, p) = command(d); let r = if app.session.in_interaction() { // Live preview already applied: keep it (the interaction commits as one undo step). - let _ = app.session.preview("effect.apply", &p); + let _ = app.session.preview(cmd, &p); app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) } else { - app.run("effect.apply", p.clone()) + app.run(cmd, p.clone()) }; - app.last_effect = Some((d.str("__effect"), p["params"].clone())); + // Last Effect repeats applied effects, not edits. + if cmd == "effect.apply" { + app.last_effect = Some((d.str("__effect"), p["params"].clone())); + } app.ui.dialog = None; r } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index ad932134..0b12280e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -32,6 +32,7 @@ mod transform; use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; +pub use effect::open as open_effect_dialog; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -142,6 +143,7 @@ registry! { GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, + EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 80ce683d..bce706c9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "effect.dialog", "Effect…", "", - "{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", ), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), @@ -472,23 +472,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - let id = s("effect").unwrap_or_default(); - match vectorcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) { - Some(e) => { - let mut fields = e.defaults.as_object().cloned().unwrap_or_default(); - fields.insert("__effect".into(), json!(e.id)); - fields.insert("__label".into(), json!(e.label.trim_end_matches('…'))); - fields.insert("preview".into(), json!(true)); - if let Some(item) = p.get("item") { - fields.insert("__item".into(), item.clone()); - } - app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); - Ok(Value::Null) - } - None => Err(format!("unknown effect `{id}`")), - } - } + "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => match app.run("recolor.colors", json!({})) { Ok(v) => { let pairs: Vec = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect(); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 066f5d37..add1a881 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1,6 +1,11 @@ -//! Appearance panel: the full stack like Illustrator — the object row, each stroke/fill row (eye, -//! disclosure, link label, swatch, weight) with "Opacity: Default" sub-rows, fx rows with inline -//! parameter editors (effect.setParams), the object's Opacity row and the bottom bar. +//! Appearance panel: the full stack as in the reference app — the object row (its thumbnail drags +//! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight, +//! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the +//! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar. +//! +//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into +//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and +//! Shift-click select several fills/strokes for Duplicate and Delete. use std::sync::OnceLock; @@ -10,8 +15,9 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; use super::{first_selected, live_run, pstate, set_pstate}; +use crate::panels::stroke::paint_profile; use crate::theme::Tokens; -use crate::widgets::{self, TransparencyEdit, menu_item}; +use crate::widgets::{self, PanelDrag, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; const ROW: f32 = 30.0; @@ -64,12 +70,56 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) - } /// Select a row; its fill/stroke becomes the active item that paint and effect edits target. +/// Other selected fills/strokes are deselected. pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { if app.session.appearance_item() != sel.item() { app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); } let owner = app.session.active().and_then(|d| d.selection.objects.first().copied()); set_pstate(ctx, "ap-sel", (owner, sel)); + set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::>())); +} + +/// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those +/// Cmd/Shift-clicked with it. +fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec { + let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] }; + let (owner, mut set): (Option, Vec) = pstate(ctx, "ap-multi"); + if owner != Some(n.id) || !set.contains(&active) { + return vec![active]; + } + set.retain(|i| *i < n.appearance.items.len()); + set.sort_unstable(); + set.dedup(); + set +} + +/// Click on fill/stroke row `i`: Shift extends the selection from the active row, Cmd toggles +/// the row, a plain click selects only it. +fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, i: usize, m: egui::Modifiers) { + let mut set = selected_items(ctx, Some(n), sel); + let active = match sel { + Sel::Item(a) if m.shift => { + set = (a.min(i)..=a.max(i)).collect(); + a + } + Sel::Item(a) if m.command => { + match set.iter().position(|x| *x == i) { + Some(p) if set.len() > 1 => { + set.remove(p); + } + Some(_) => {} + None => set.push(i), + } + if set.contains(&a) { a } else { set[0] } + } + _ => { + set = vec![i]; + i + } + }; + select_row(app, ctx, Sel::Item(active)); + set_pstate(ctx, "ap-multi", (Some(n.id), set)); } fn catalog() -> &'static [(String, String, Vec)] { @@ -184,12 +234,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); chip(ui, th, &fill); + if let Some(n) = node.as_ref().filter(|_| !mixed) { + thumbnail_drag(ui, th, n.id); + } } text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true); match &node { // Objects that differ have no common stack to list. Some(_) if mixed => {} - Some(n) => stack(app, ui, n, sel), + Some(n) => stack(app, ui, n, sel, r), None => default_stack(app, ui), } // Spacer row like Illustrator's empty tail. @@ -198,6 +251,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, node.as_ref(), sel); } +/// The object row's thumbnail drags this object's appearance onto art ([`PanelDrag::Appearance`]; +/// a chip of its fill follows the pointer). +fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) { + let resp = ui.interact(th, ui.id().with("ap-thumb"), Sense::drag()).on_hover_text("Drag onto art to apply this appearance"); + widgets::drag_source(ui, &resp, || PanelDrag::Appearance(id)); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } +} + /// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" /// or the object's kind ("Path", "Type", …). pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { @@ -234,16 +297,43 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false); } -fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { +/// A stack row being dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Dragged { + Item(usize), + /// Effect `k` of fill/stroke `item` (`None`: of the object). + Effect(Option, usize), +} + +/// What a stack row stands for when a dragged row is dropped on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Slot { + /// Fill/stroke row `index` (`sub`: one of its indented sub-rows). + Item { index: usize, sub: bool }, + /// Effect `k` of fill/stroke `item` (`None`: of the object). + Effect(Option, usize), + /// The object row (`top`) or the object's Opacity row. + Object { top: bool }, +} + +fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) { let t = Tokens::get(ui.ctx()); - let mut drop: Option<(usize, usize)> = None; + let ctx = ui.ctx().clone(); + let dragging: Option = pstate(&ctx, "ap-drag"); + let items = selected_items(&ctx, Some(n), sel); + let mut rows = vec![(Slot::Object { top: true }, object_row)]; let mut stopped = false; - let dragging: Option = pstate(ui.ctx(), "ap-drag"); - let mut row_rects = vec![]; + // Starts or ends a row drag. + let mut track = |resp: &egui::Response, what: Dragged| { + if resp.drag_started() { + set_pstate(&ctx, "ap-drag", Some(what)); + } + stopped |= resp.drag_stopped(); + }; for (i, it) in n.appearance.items.iter().enumerate().rev() { let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); - let (r, resp) = row(ui, sel == Sel::Item(i)); - row_rects.push((i, r)); + let (r, resp) = row(ui, items.contains(&i)); + rows.push((Slot::Item { index: i, sub: false }, r)); let (visible, paint) = (it.visible(), it.paint()); let is_stroke = !it.is_fill(); if eye(ui, r, ("ap-eye", i), visible, true) { @@ -261,11 +351,19 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { } else { text(ui, pos2(lx, r.center().y), "Fill:", false); } - // Swatch with a swatches popup that sets this item's paint. + // Swatch with a swatches popup that sets this item's paint; Shift-click opens the Color + // panel's mixer on this row instead. let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); chip(ui, cr, paint); - let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch"); - egui::Popup::menu(&cresp).show(|ui| { + let cresp = ui + .interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()) + .on_hover_text("Click to choose a swatch, Shift-click to mix a colour"); + let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift); + if mixer { + select_row(app, ui.ctx(), Sel::Item(i)); + app.ui.open_panel = Some("color".into()); + } + egui::Popup::menu(&cresp).open_memory((cresp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { swatch_picker(app, ui, i); }); if let AppearanceItem::Stroke(st) = it { @@ -274,67 +372,139 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } + stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); } if resp.clicked() { - select_row(app, ui.ctx(), Sel::Item(i)); + let m = ui.input(|inp| inp.modifiers); + click_item(app, ui.ctx(), n, sel, i, m); } if resp.double_clicked() { app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); } - if resp.drag_started() { - set_pstate(ui.ctx(), "ap-drag", Some(i)); - } - if resp.drag_stopped() { - stopped = true; - } + track(&resp, Dragged::Item(i)); if open { - opacity_row(app, ui, Some(i), it.opacity(), it.blend()); + let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend()); + rows.push((Slot::Item { index: i, sub: true }, r)); for (k, e) in it.effects().iter().enumerate() { - effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); + let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); + rows.push((Slot::Effect(Some(i), k), r)); + track(&resp, Dragged::Effect(Some(i), k)); } } } - if stopped - && let Some(from) = dragging - && let Some(p) = ui.ctx().pointer_interact_pos() - { - drop = Some((from, target_index(&row_rects, p.y))); - } - if dragging.is_some() && !ui.ctx().input(|i| i.pointer.any_down()) { - set_pstate::>(ui.ctx(), "ap-drag", None); - } - if let Some(p) = ui.ctx().pointer_interact_pos() - && dragging.is_some() - { - // Insertion marker. - if let Some((_, r)) = row_rects.iter().find(|(_, r)| r.y_range().contains(p.y)) { - let y = if p.y < r.center().y { r.top() } else { r.bottom() }; - ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - } - } // Object-level effects. for (k, e) in n.appearance.effects.iter().enumerate() { - effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); + let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); + rows.push((Slot::Effect(None, k), r)); + track(&resp, Dragged::Effect(None, k)); } - opacity_row(app, ui, None, n.opacity, n.blend); - if let Some((from, to_row)) = drop - && from != to_row + let r = opacity_row(app, ui, None, n.opacity, n.blend); + rows.push((Slot::Object { top: false }, r)); + let Some(what) = dragging else { return }; + let pointer = ui.ctx().pointer_interact_pos(); + if let Some(p) = pointer + && let Some((slot, r)) = slot_at(&rows, p) { - // The engine keeps the moved row active when it was. - app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); + // Drop marker: a line where the row lands, or a frame round the row it goes into. + let stroke = Stroke::new(2.0, t.accent); + let line = |y: f32| { + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], stroke); + }; + match (what, slot) { + (Dragged::Effect(..), Slot::Effect(..)) => line(if p.y < r.center().y { r.top() } else { r.bottom() }), + (Dragged::Effect(..), _) => { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); + } + (Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }), + } + } + if !ui.ctx().input(|i| i.pointer.any_down()) { + set_pstate::>(ui.ctx(), "ap-drag", None); + } + if stopped + && let Some(p) = pointer + && let Some((cmd, params)) = drop_command(n, &rows, what, p, ui.input(|i| i.modifiers.alt)) + && let Ok(r) = app.run(cmd, params) + && let Some(k) = r["index"].as_u64().filter(|_| cmd == "effect.move") + { + // The dropped effect becomes the selected row (a moved fill/stroke stays active in the engine). + let k = k as usize; + select_row(app, ui.ctx(), r["item"].as_u64().map_or(Sel::Effect(k), |i| Sel::ItemEffect(i as usize, k))); } } -/// Paint-order index a drop at `y` lands on (rows are listed top = last item). -fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { - rows.iter().find(|(_, r)| r.y_range().contains(y)).map(|(i, _)| *i).unwrap_or_else(|| { - if rows.first().is_some_and(|(_, r)| y < r.top()) { rows.first().map(|r| r.0).unwrap_or(0) } else { rows.last().map(|r| r.0).unwrap_or(0) } - }) +/// The row under `p`. +fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> { + rows.iter().find(|(_, r)| r.contains(p)).copied() +} + +/// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item; +/// the object row stands above the topmost item, the rows below the items under item 0. +fn item_at(n: &Node, slot: Slot) -> usize { + match slot { + Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index, + Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1), + Slot::Object { top: false } | Slot::Effect(None, _) => 0, + } +} + +/// The command a drop of `what` at `p` runs (`copy`: Alt held), or None when it would change +/// nothing or lands outside the stack. +fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, copy: bool) -> Option<(&'static str, Value)> { + let (slot, r) = slot_at(rows, p)?; + match what { + Dragged::Item(from) => { + let to = item_at(n, slot); + if copy { + // The copy goes where the row was dropped: above that row when dragged up, below it + // when dragged down. + let at = if to >= from { to + 1 } else { to }; + Some(("appearance.duplicateItem", json!({"index": from, "to": at}))) + } else { + (to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to}))) + } + } + Dragged::Effect(from_item, k) => { + let (item, pos) = match slot { + Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)), + Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()), + Slot::Object { .. } => (None, n.appearance.effects.len()), + }; + // Within its own list the effect leaves its old place first. + let same = item == from_item && !copy; + let pos = if same && pos > k { pos - 1 } else { pos }; + if same && pos == k { + return None; + } + Some(("effect.move", json!({"from": k, "fromItem": from_item, "to": pos, "toItem": item, "copy": copy}))) + } + } +} + +/// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn). +fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter_at(r); + let dashed = st.dash.as_ref().is_some_and(|d| d.is_dashed()).then_some("Dashed"); + let notes: Vec<&str> = st.brush.as_deref().into_iter().chain(dashed).collect(); + let mut x = r.left(); + if !notes.is_empty() { + let g = painter.layout_no_wrap(notes.join(", "), egui::FontId::proportional(11.5), t.text_dim); + x += g.size().x + 6.0; + painter.galley(pos2(r.left(), r.center().y - g.size().y / 2.0), g, t.text_dim); + } + if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) { + let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0)); + if pr.right() <= r.right() { + paint_profile(ui, pr, Some(p), t.text); + } + } } /// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it /// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). -fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) { +/// Returns the row's rect. +fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) -> Rect { let (r, resp) = row(ui, false); eye(ui, r, ("ap-op-eye", item), true, false); let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; @@ -350,15 +520,19 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opaci Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase), None => {} }); + r } /// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. -fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect, selected: bool) { +/// Clicking the name opens the effect's dialog to edit it (the disclosure: an inline editor). +/// Returns the row's rect and response (for dragging). +fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect, selected: bool) -> (Rect, egui::Response) { let t = Tokens::get(ui.ctx()); let open_key = format!("ap-fx-open-{item:?}-{k}"); let open: bool = pstate(ui.ctx(), &open_key); let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k)); - let (r, resp) = row(ui, selected); + let (row_rect, resp) = row(ui, selected); + let r = row_rect; if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) { app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok(); } @@ -369,8 +543,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi } let lx = r.left() + EYE_W + 24.0; if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) { - set_pstate(ui.ctx(), &open_key, !open); select_row(app, ui.ctx(), this); + if has_options(&e.id) { + app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok(); + } else { + set_pstate(ui.ctx(), &open_key, !open); + } } icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); if resp.clicked() { @@ -382,6 +560,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi if open { effect_editor(app, ui, item, k, e); } + (row_rect, resp) +} + +/// Does the effect have a dialog (any parameters)? +fn has_options(id: &str) -> bool { + vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty())) } /// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); @@ -502,31 +686,31 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() { app.run("appearance.clear", json!({})).ok(); } - let can_dup = has && sel != Sel::None; - if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() { - duplicate_selected(app, sel); + let can = has && sel != Sel::None; + if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() { + duplicate_selected(app, ui, node, sel); } - let can_del = has && sel != Sel::None; - if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() { - delete_selected(app, ui, sel); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can, 24.0).clicked() { + delete_selected(app, ui, node, sel); } let _ = basic; }); } -fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) { - match (sel, sel.effect()) { - (Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(), - (_, Some(fx)) => app.run("effect.duplicate", fx).ok(), - _ => None, +/// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect. +fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { + match sel.effect() { + Some(fx) => app.run("effect.duplicate", fx).ok(), + None if sel.item().is_some() => app.run("appearance.duplicateItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).ok(), + None => None, }; } -fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { - let ok = match (sel, sel.effect()) { - (Sel::Item(i), _) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), - (_, Some(fx)) => app.run("effect.remove", fx).is_ok(), - _ => false, +fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { + let ok = match sel.effect() { + Some(fx) => app.run("effect.remove", fx).is_ok(), + None if sel.item().is_some() => app.run("appearance.removeItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).is_ok(), + None => false, }; // Removing the active item clears it in the engine; a removed item effect leaves its item // selected. @@ -535,8 +719,9 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { } } -/// The fx menu: effects grouped by their Effect-menu submenu; opens the effect dialog. -fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { +/// The fx menu (Appearance panel, Properties): effects grouped by their Effect-menu submenu; +/// opens the effect dialog. +pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; for (id, label, menu) in catalog() { let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); @@ -569,10 +754,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) { - duplicate_selected(app, sel); + duplicate_selected(app, ui, node.as_ref(), sel); } if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { - delete_selected(app, ui, sel); + delete_selected(app, ui, node.as_ref(), sel); } if menu_item(ui, "Clear Appearance", has, false) { app.run("appearance.clear", json!({})).ok(); @@ -592,7 +777,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, &redefine, style.is_some(), false) { app.run("graphicStyle.redefine", json!({})).ok(); } - menu_item(ui, "Show All Hidden Attributes", false, false); + if menu_item(ui, "Show All Hidden Attributes", node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) { + app.run("appearance.showAllHidden", json!({})).ok(); + } } #[cfg(test)] @@ -615,11 +802,42 @@ mod tests { } #[test] - fn drop_targets() { - let r = |y: f32| Rect::from_min_size(pos2(0.0, y), vec2(100.0, ROW)); - let rows = vec![(2, r(0.0)), (1, r(30.0)), (0, r(60.0))]; - assert_eq!(target_index(&rows, 45.0), 1); - assert_eq!(target_index(&rows, -10.0), 2); - assert_eq!(target_index(&rows, 500.0), 0); + fn drops_pick_the_command() { + use vectorcraft_doc::{Appearance, FillLayer}; + let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true }; + let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art()); + n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F] + n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]); + n.appearance.effects.push(fx("stylize.dropShadow")); + let slots = [ + Slot::Object { top: true }, + Slot::Item { index: 2, sub: false }, + Slot::Item { index: 1, sub: false }, + Slot::Effect(Some(1), 0), + Slot::Effect(Some(1), 1), + Slot::Item { index: 0, sub: false }, + Slot::Effect(None, 0), + Slot::Object { top: false }, + ]; + let rows: Vec<(Slot, Rect)> = + slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); + // The upper or lower half of row `i`. + let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 }); + let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy); + // Fill/stroke rows: moves, Alt copies to where it was dropped. + assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2})))); + assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3})))); + assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0})))); + assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect"); + // Effects: within the list (below the next one), into a fill, out onto the object. + let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| { + Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy}))) + }; + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false)); + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself"); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true)); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false)); + assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false)); + assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack"); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6efe1ca8..65672903 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -356,3 +356,5 @@ mod tests { } #[cfg(test)] mod tests_appearance; +#[cfg(test)] +mod tests_effectedit; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d88342ea..36a8b857 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -307,10 +307,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { } }); ui.horizontal(|ui| { - let r = widgets::flat_button(ui, "fx", 34.0); - if r.on_hover_text("Effects").clicked() { - app.ui.open_panel = Some("appearance".into()); - } + // The fx button opens the effect menu, as the Appearance panel's does. + let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text("Add New Effect"); + egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui)); if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() { app.ui.open_panel = Some("appearance".into()); } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 4a82cb32..886b1426 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -342,16 +342,21 @@ enum Drop { impl Drop { /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows - /// it carries, or make a swatch of a paint dragged from elsewhere. - fn of(d: &PanelDrag, target: Option, after: bool) -> Self { - let PanelDrag::Paint { paint, rows, .. } = d; - match rows { - Some(r) => Drop::Move { names: r.names.clone(), target, after }, - None => Drop::New { paint: paint.clone(), target }, + /// it carries, or make a swatch of a paint dragged from elsewhere (appearances: nothing). + fn of(d: &PanelDrag, target: Option, after: bool) -> Option { + match d { + PanelDrag::Paint { rows: Some(r), .. } => Some(Drop::Move { names: r.names.clone(), target, after }), + PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target }), + PanelDrag::Appearance(_) => None, } } } +/// The drag held over `resp`, if the panel takes it (paints, not the Appearance panel's thumbnail). +fn held(resp: &Response) -> Option> { + resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. })) +} + /// The drag a tile or row of `e` starts, moving rows `names`. Registration and colour groups have /// no paint of their own (the chip under the pointer draws Registration's mark). fn drag_of(e: &Entry, names: Vec) -> PanelDrag { @@ -404,8 +409,8 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String /// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after /// it by the pointer's half, or into a folder); a release there sets the drop. fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { - let Some(d) = resp.dnd_hover_payload::() else { return }; - let PanelDrag::Paint { rows, .. } = &*d; + let Some(d) = held(resp) else { return }; + let PanelDrag::Paint { rows, .. } = &*d else { return }; let name = e.name(); ev.over = true; // Swatches dropped on one of themselves stay where they are. @@ -428,19 +433,19 @@ fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvent ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); } if resp.dnd_release_payload::().is_some() { - ev.drop = Some(Drop::of(&d, Some(name.to_string()), after)); + ev.drop = Drop::of(&d, Some(name.to_string()), after); } } /// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped /// swatches (colour groups to the end of the groups); the list is outlined while one is held there. fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { - let Some(d) = zone.dnd_hover_payload::() else { return }; + let Some(d) = held(zone) else { return }; if !ev.over { ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); } if zone.dnd_release_payload::().is_some() { - ev.drop = Some(Drop::of(&d, None, false)); + ev.drop = Drop::of(&d, None, false); } } @@ -509,16 +514,20 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } } -/// While a paint is dragged (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail), a chip -/// of it follows the pointer. +/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, +/// a Fill/Stroke proxy, the Gradient panel's thumbnail), or the fill of the object whose +/// appearance the Appearance panel's thumbnail carries. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; - let PanelDrag::Paint { paint, params, rows } = &*d; - // Colour groups paint nothing. - if params.is_null() { - return; - } - let registration = rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION); + let (paint, registration) = match &*d { + // Colour groups paint nothing. + PanelDrag::Paint { params, .. } if params.is_null() => return, + PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), + PanelDrag::Appearance(id) => match app.session.active().and_then(|st| st.doc.node(*id)) { + Some(n) => (std::borrow::Cow::Owned(n.appearance.fill_paint()), false), + None => return, + }, + }; let Some(at) = ctx.pointer_hover_pos() else { return }; let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); area.interactable(false).show(ctx, |ui| { @@ -526,8 +535,8 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { if registration { draw_registration(ui, r); } else { - swatch_tile(ui, r, paint, false, false); - pattern_thumb(app, ui, r.shrink(1.0), paint); + swatch_tile(ui, r, &paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), &paint); } }); } diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index fdfc53d4..52dccceb 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -6,7 +6,7 @@ use vectorcraft_engine::Session; use super::*; -fn app_with_rect() -> VectorcraftApp { +pub(super) fn app_with_rect() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); run(&mut app, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); @@ -14,7 +14,7 @@ fn app_with_rect() -> VectorcraftApp { app } -fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { +pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) } @@ -29,13 +29,23 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut Ve } /// One headless frame with input `events`: every text drawn, with its screen rect. -fn frame_events( +pub(super) fn frame_events( ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, + f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec<(String, egui::Rect)> { + frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, f) +} + +/// One headless frame with input `raw`: every text drawn, with its screen rect. +pub(super) fn frame_raw( + ctx: &egui::Context, + app: &mut VectorcraftApp, + raw: egui::RawInput, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), ) -> Vec<(String, egui::Rect)> { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| f(app, ui)); + let mut out = ctx.run_ui(raw, |ui| f(app, ui)); out.textures_delta.clear(); fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { match s { @@ -52,7 +62,7 @@ fn frame_events( } /// Click at `pos` over three frames of `f` (hover, press, release). -fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) { +pub(super) fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) { let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { frame_events(ctx, app, vec![e], &mut f); @@ -60,7 +70,7 @@ fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: } /// The rect of the first drawn text equal to `s`. -fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { +pub(super) fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1 } diff --git a/crates/ui-egui/src/panels/tests_effectedit.rs b/crates/ui-egui/src/panels/tests_effectedit.rs new file mode 100644 index 00000000..f000cbd0 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_effectedit.rs @@ -0,0 +1,196 @@ +//! Headless frames of the Appearance panel's editing depth: effect names open their dialog +//! prefilled, rows drag (Alt copies), several fills/strokes select together, stroke notes, the +//! chip's Shift-click, the thumbnail drag and the Properties fx button. + +use serde_json::{Value, json}; + +use super::tests_appearance::{app_with_rect, click, frame_events, frame_raw, run, text_rect}; +use super::*; + +fn pointer(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers } +} + +/// One frame of the Appearance panel with `events` and `modifiers` held. +fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> { + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, appearance::show) +} + +/// Drag from `from` to `to` in the Appearance panel, `modifiers` held on release. +fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) { + let none = egui::Modifiers::NONE; + panel_frame(ctx, app, vec![egui::Event::PointerMoved(from)], none); + panel_frame(ctx, app, vec![pointer(from, true, none)], none); + panel_frame(ctx, app, vec![egui::Event::PointerMoved(from + egui::vec2(0.0, 8.0))], none); + panel_frame(ctx, app, vec![egui::Event::PointerMoved(to)], modifiers); + panel_frame(ctx, app, vec![pointer(to, false, modifiers)], modifiers); + panel_frame(ctx, app, vec![], none); +} + +/// Click `pos` with `modifiers` held. +fn click_with(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, modifiers: egui::Modifiers) { + for e in [egui::Event::PointerMoved(pos), pointer(pos, true, modifiers), pointer(pos, false, modifiers)] { + panel_frame(ctx, app, vec![e], modifiers); + } +} + +/// A point in the row of the label `s` (right of its chip, weight and notes). +fn row_of(texts: &[(String, egui::Rect)], s: &str) -> egui::Pos2 { + let r = text_rect(texts, s); + egui::pos2(r.left() + 200.0, r.center().y) +} + +/// The rect of the panel-menu entry `s` (entries are drawn after a check-mark column). +fn menu_item(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { + texts.iter().find(|(t, _)| t.trim_start() == s).unwrap_or_else(|| panic!("`{s}` not in the menu: {texts:?}")).1 +} + +fn effects(app: &VectorcraftApp, item: Option) -> Vec { + first_selected(app).unwrap().appearance.effects_at(item).cloned().unwrap_or_default() +} + +#[test] +fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 9}})); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click(&ctx, &mut app, text_rect(&texts, "Roughen").center(), appearance::show); + let d = app.ui.dialog.clone().expect("the effect's dialog"); + assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0))); + // The dialog previews; OK keeps the new value on the same effect as one undo step. + let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = effects(&app, None); + assert_eq!((fx.len(), fx[0].params["size"].clone()), (1, json!(20))); + assert!(app.ui.dialog.is_none() && app.last_effect.is_none(), "an edit is not a new Last Effect"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effects(&app, None)[0].params["size"], json!(9)); +} + +#[test] +fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 0, "params": {"size": 9}})); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + // The active fill already has it: the question comes first; OK edits the applied one. + assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(), json!({"pending": "effectExists"})); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), ("effect", 9.0, json!(0), json!(0))); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(effects(&app, Some(0)).len(), 1); + // "Add New": a fresh dialog with the defaults, which adds a second one. + app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(d.fields.get("__index").is_none() && d.f64("size", 0.0) == 5.0, "{:?}", d.fields); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(effects(&app, Some(0)).len(), 2); + // Another list without it opens the dialog directly. + assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen", "item": null})).unwrap(), Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effect"); +} + +#[test] +fn effect_rows_drag_to_reorder_and_into_an_item() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen"})); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // Twist below Roughen. + let below = text_rect(&texts, "Roughen").center() + egui::vec2(60.0, 9.0); + drag(&ctx, &mut app, row_of(&texts, "Twist"), below, egui::Modifiers::NONE); + let ids = |fx: Vec| fx.into_iter().map(|e| e.id).collect::>(); + assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]); + // Alt-dragging Twist onto the stroke row copies it into the stroke's effects. + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + drag(&ctx, &mut app, row_of(&texts, "Twist"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT); + assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]); + assert_eq!(ids(effects(&app, Some(1))), ["distort.twist"]); + // Dropped effects become the selected row (their item active). + assert_eq!(app.session.appearance_item(), Some(1)); + app.run("edit.undo", json!({})).unwrap(); + assert!(effects(&app, Some(1)).is_empty()); +} + +#[test] +fn alt_dragging_an_item_duplicates_it_and_several_items_delete_together() { + let mut app = app_with_rect(); // [Fill, Stroke] + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + drag(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT); + let kinds = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.items.iter().map(|i| i.kind_name()).collect::>(); + assert_eq!(kinds(&app), ["fill", "stroke", "fill"]); + // Click the stroke, Cmd-click the top fill: both go with Remove Item. + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + click_with(&ctx, &mut app, row_of(&texts, "Stroke:"), egui::Modifiers::NONE); + let top_fill = texts.iter().filter(|(t, _)| t == "Fill:").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click_with(&ctx, &mut app, egui::pos2(top_fill.left() + 200.0, top_fill.center().y), egui::Modifiers::COMMAND); + let menu = frame_events(&ctx, &mut app, vec![], appearance::menu); + click(&ctx, &mut app, menu_item(&menu, "Remove Item").center(), appearance::menu); + assert_eq!(kinds(&app), ["fill"]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&app).len(), 3, "one undo step"); +} + +#[test] +fn stroke_rows_note_dashes_brushes_and_show_all_hidden_attributes() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + assert!(!frame_events(&ctx, &mut app, vec![], appearance::show).iter().any(|(t, _)| t == "Dashed")); + run(&mut app, "stroke.set", json!({"dash": [4, 2], "profile": "lens"})); + let brush = run(&mut app, "brush.list", json!({}))["brushes"][0]["name"].as_str().unwrap().to_string(); + run(&mut app, "brush.apply", json!({ "name": brush })); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + assert!(texts.iter().any(|(t, _)| *t == format!("{brush}, Dashed")), "{texts:?}"); + // Show All Hidden Attributes is enabled once something is hidden, and shows it. + let enabled = |app: &mut VectorcraftApp| { + let menu = frame_events(&ctx, app, vec![], appearance::menu); + let r = menu_item(&menu, "Show All Hidden Attributes"); + click(&ctx, app, r.center(), appearance::menu); + }; + run(&mut app, "appearance.setItem", json!({"index": 0, "visible": false})); + enabled(&mut app); + assert!(first_selected(&app).unwrap().appearance.items[0].visible()); +} + +#[test] +fn shift_clicking_a_chip_opens_the_mixer_on_that_row() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // The chip sits 52 pt right of the label's start (label at EYE_W + 24, chip at EYE_W + 76). + let label = text_rect(&texts, "Fill:"); + click_with(&ctx, &mut app, egui::pos2(label.left() + 52.0 + 9.0, label.center().y), egui::Modifiers::SHIFT); + assert_eq!(app.ui.open_panel.as_deref(), Some("color")); + assert_eq!(app.session.appearance_item(), Some(0)); +} + +#[test] +fn the_thumbnail_drags_the_appearance_and_the_properties_fx_button_opens_the_effects() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // The thumbnail is left of the object row's label. + let label = text_rect(&texts, "Rectangle"); + let thumb = egui::pos2(label.left() - 28.0, label.center().y); + let none = egui::Modifiers::NONE; + for e in [egui::Event::PointerMoved(thumb), pointer(thumb, true, none), egui::Event::PointerMoved(thumb + egui::vec2(30.0, 30.0))] { + panel_frame(&ctx, &mut app, vec![e], none); + } + let id = first_selected(&app).unwrap().id; + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&crate::widgets::PanelDrag::Appearance(id))); + // Properties: the fx button lists the effect groups. + let props = egui::Context::default(); + let texts = frame_raw(&props, &mut app, Default::default(), properties::show); + click(&props, &mut app, text_rect(&texts, "fx").center(), properties::show); + let texts = frame_raw(&props, &mut app, Default::default(), properties::show); + assert!(texts.iter().any(|(t, _)| t == "Distort & Transform"), "{texts:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bffab921..70b89b21 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -955,6 +955,9 @@ pub enum PanelDrag { /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes /// a swatch of a paint dragged from elsewhere. Paint { paint: Paint, params: serde_json::Value, rows: Option }, + /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance + /// (`appearance.copyFrom`). + Appearance(vectorcraft_doc::NodeId), } /// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour @@ -982,6 +985,7 @@ impl PanelDrag { pub fn color(&self) -> Option { match self { Self::Paint { paint, .. } => paint.color(), + Self::Appearance(_) => None, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 74b29c1b..6cfa113f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,3 +53,10 @@ Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gra for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference. + +Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog +(fields: the effect's parameters, `preview`). With `index` it edits that applied effect of `item` (null: the object's +effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setParams`; otherwise confirm runs +`effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the +`effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", +value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. diff --git a/docs/mcp.md b/docs/mcp.md index 9f546593..f05d937c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -224,6 +224,8 @@ object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the obj style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}` updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the style of the first selected object; `select.same.graphicStyle` selects an object's fellow users. +Styles keep placed gradients relative to the bounds of the object they were made from, and each object a +style is applied to gets them at the same place relative to its own bounds. ```json {"name":"run_command","arguments":{"command":"graphicStyle.new","params":{"id":12,"name":"Glow"}}} @@ -231,3 +233,12 @@ style of the first selected object; `select.same.graphicStyle` selects an object {"name":"run_command","arguments":{"command":"graphicStyle.redefine","params":{"id":12}}} {"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}} ``` + +## Editing appearance stacks + +`effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects +(`null`) and a fill's or stroke's (an item index), as dragging its row in the Appearance panel does; `copy: true` copies +it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appearance.removeItem {index | indices}` +act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect +visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled +(`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question.