Merge wp/D2

# Conflicts:
#	crates/pdf/src/export.rs
#	crates/render/src/fx.rs
#	crates/render/src/lib.rs
#	crates/svg/src/export.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/dialogs/mod.rs
#	crates/ui-egui/src/panels/appearance.rs
#	docs/control-protocol.md
#	docs/mcp.md
This commit is contained in:
bflatastic 2026-10-04 23:46:38 +07:00
commit 43d7ac420d
29 changed files with 1742 additions and 251 deletions

View File

@ -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<Effect>| 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();

View File

@ -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<Node> {
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));

View File

@ -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),

View File

@ -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<Node> {
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<Arc<Node>> = 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<Node> {
let hook: &dyn Fn(&Node) -> Option<Node> = &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<Node> {
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, &params, &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 }
};
}

View File

@ -66,7 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<ItemTarget> {
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<ItemTarget> {
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<Value> {
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<Vec<usize>> {
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::<Option<Vec<_>>>())
.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<Value> {
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<Value> {
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<Value> {
let roots = appearance_targets(s, p)?;
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = 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::<Vec<_>>() }))
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let from = index_param(p, "from", C)?;

View File

@ -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<CommandSpec> {
@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;

View File

@ -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;

View File

@ -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<usize>) -> Vec<String> {
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::<Vec<_>>();
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());
}

View File

@ -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<String>) {
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);
}
}

View File

@ -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;

View File

@ -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<usize>) -> Vec<String> {
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:?}");
}
}

View File

@ -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<usize>,
/// 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<BezPath> {
match &n.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
@ -101,6 +131,12 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
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<f32>, 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<Node>, 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<Node>, cache: bool) -> Arc<Node> {
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);
}
}
}

View File

@ -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<usize, (Arc<Node>, Arc<Node>)>,
}
#[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;

View File

@ -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<Effect>) -> 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<Effect>) -> 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<Effect>) -> 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<u32> {
(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}");
}

View File

@ -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!("<g{id}{a}>"));
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("</g>");
@ -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 `<g filter>`
/// 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 `<g filter>` 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::<f64>() + 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!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">"));
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("</g>");
@ -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))
}

View File

@ -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("<filter ").count(), 1, "{svg}");
// The filter group holds the stroke's path, and the fill's path comes before it, unfiltered.
let open = svg.find("<g filter=\"url(#").expect("a filter group");
let inside = &svg[open..svg[open..].find("</g>").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("<filter "));
}
#[test]
fn geometry_effects_on_type_export_as_its_reshaped_outlines() {
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(10.0, 60.0), "Hi", style))));
let plain = export(&doc_with(n.clone()), &ExportOptions::default());
assert!(plain.contains("<text"), "{plain}");
n.appearance.effects =
vec![Effect { id: "distort.roughen".into(), params: json!({"size": 3, "relative": false}), visible: true }, fx("stylize.dropShadow")];
let svg = export(&doc_with(n), &ExportOptions::default());
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
assert!(svg.contains("<filter "), "the shadow stays a filter: {svg}");
}

View File

@ -676,8 +676,9 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it). A
/// chip of a dragged paint follows the pointer meanwhile.
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
/// (`appearance.copyFrom`). A chip follows the pointer meanwhile.
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
crate::panels::swatches::drag_preview(app, ui.ctx());
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
@ -699,6 +700,13 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
params["focus"] = json!(false);
(crate::panels::proxy_cmd(app, false), params)
}
widgets::PanelDrag::Appearance(source) => {
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);
}
}

View File

@ -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<Value, String> {
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<vectorcraft_doc::Effect> {
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<String, Value> {
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<Value, String> {
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<Value, String> {
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
}

View File

@ -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).

View File

@ -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<R
app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null));
Ok(app.session.tool_options())
}
"effect.dialog" => {
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<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect();

View File

@ -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::<Vec<usize>>()));
}
/// 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<usize> {
let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] };
let (owner, mut set): (Option<NodeId>, Vec<usize>) = 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<String>)] {
@ -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>, 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>, 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<Dragged> = 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<usize> = 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::<Option<usize>>(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::<Option<Dragged>>(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<usize>, opacity: f32, blend: BlendMode) {
/// Returns the row's rect.
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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");
}
}

View File

@ -356,3 +356,5 @@ mod tests {
}
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_effectedit;

View File

@ -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());
}

View File

@ -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<String>, 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<String>, after: bool) -> Option<Self> {
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<std::sync::Arc<PanelDrag>> {
resp.dnd_hover_payload::<PanelDrag>().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<String>) -> 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::<PanelDrag>() 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::<PanelDrag>().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::<PanelDrag>() 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::<PanelDrag>().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::<PanelDrag>(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);
}
});
}

View File

@ -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<egui::Event>,
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
}

View File

@ -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<egui::Event>, 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<usize>) -> Vec<vectorcraft_doc::Effect> {
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<vectorcraft_doc::Effect>| fx.into_iter().map(|e| e.id).collect::<Vec<_>>();
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::<Vec<_>>();
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::<crate::widgets::PanelDrag>(&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:?}");
}

View File

@ -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<SwatchRows> },
/// 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<vectorcraft_color::Color> {
match self {
Self::Paint { paint, .. } => paint.color(),
Self::Appearance(_) => None,
}
}
}

View File

@ -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.

View File

@ -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.