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:
commit
43d7ac420d
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, &path, bounds, &GeomContext::of(leaf));
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.opacity = n.opacity;
|
||||
out.blend = n.blend;
|
||||
out.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.mask = n.mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Run `f` on every path and compound path under `n` (guides skipped).
|
||||
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
for_each_leaf(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths).
|
||||
fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) {
|
||||
let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. });
|
||||
n.kind = if path.subpaths.len() > 1 {
|
||||
let id = n.id;
|
||||
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect();
|
||||
NodeKind::Compound { children, rule }
|
||||
} else {
|
||||
NodeKind::Path { path, rule, live: None, clipping, guide: false }
|
||||
};
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,3 +356,5 @@ mod tests {
|
|||
}
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -224,6 +224,8 @@ object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the obj
|
|||
style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}`
|
||||
updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the
|
||||
style of the first selected object; `select.same.graphicStyle` selects an object's fellow users.
|
||||
Styles keep placed gradients relative to the bounds of the object they were made from, and each object a
|
||||
style is applied to gets them at the same place relative to its own bounds.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"graphicStyle.new","params":{"id":12,"name":"Glow"}}}
|
||||
|
|
@ -231,3 +233,12 @@ style of the first selected object; `select.same.graphicStyle` selects an object
|
|||
{"name":"run_command","arguments":{"command":"graphicStyle.redefine","params":{"id":12}}}
|
||||
{"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}}
|
||||
```
|
||||
|
||||
## Editing appearance stacks
|
||||
|
||||
`effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects
|
||||
(`null`) and a fill's or stroke's (an item index), as dragging its row in the Appearance panel does; `copy: true` copies
|
||||
it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appearance.removeItem {index | indices}`
|
||||
act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect
|
||||
visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled
|
||||
(`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question.
|
||||
|
|
|
|||
Loading…
Reference in New Issue