Merge pull request #43 from storytold/pr/m3-targeting-expand-appearance

M3: target circles and full Expand Appearance (M3.69, M3.70)
This commit is contained in:
BFlat
2026-10-05 02:20:06 +07:00
committed by GitHub
23 changed files with 1116 additions and 163 deletions
+45
View File
@@ -19,6 +19,11 @@ pub struct Selection {
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// The object, group or layer targeted through its target circle in the Layers panel, which
/// appearance, transparency and opacity-mask commands then act on (a targeted layer has its
/// art selected). Any other selection change clears it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<NodeId>,
}
impl Selection {
@@ -35,6 +40,7 @@ impl Selection {
self.objects.clear();
self.anchors.clear();
self.key = None;
self.target = None;
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
@@ -43,11 +49,13 @@ impl Selection {
}
}
pub fn add(&mut self, id: NodeId) {
self.target = None;
if !self.objects.contains(&id) {
self.objects.push(id);
}
}
pub fn remove(&mut self, id: NodeId) {
self.target = None;
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
if self.key == Some(id) {
@@ -68,6 +76,26 @@ impl Selection {
if self.key.is_some_and(|k| doc.node(k).is_none()) {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
self.target = None;
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
/// anything else is selected itself.
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
match doc.node(id) {
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
_ => self.set([id]),
}
self.target = Some(id);
}
/// What appearance and transparency edits act on: the targeted object, group or layer, else
/// the selected objects.
pub fn subjects(&self) -> &[NodeId] {
match &self.target {
Some(t) => std::slice::from_ref(t),
None => &self.objects,
}
}
/// Top-level ordering: selected ids sorted by paint order (bottom first).
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
@@ -93,4 +121,21 @@ mod tests {
s.clear();
assert!(s.is_empty());
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
}
+107 -22
View File
@@ -6,13 +6,16 @@
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use crate::group::has_own_paint;
use crate::group::{has_own_paint, paints};
use crate::{GeomContext, apply_geometry_with, evaluate_container, has_geometry, has_pathfinder, is_geometry, needs_outline, reshape};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -126,18 +129,48 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
}
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
/// none of `n`'s transparency.
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
let id = d.alloc_id();
let kind = path_kind(d, path, rule);
Node {
id,
name: None,
appearance: Appearance { items: vec![item], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,
graph: None,
kind,
..n.clone()
}
}
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
/// its effects, and `n` without those effects.
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id));
Some((g, rule, ctx, m))
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (g, rule, ctx, mut m) = leaf_base(n)?;
if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) {
m.kind = path_kind(d, g, rule);
return Some(m);
}
// Per-item geometry: one path per fill/stroke, grouped under the object's transparency and
// raster effects (what Expand Appearance produces).
// raster effects.
let children = n
.appearance
.items
@@ -146,24 +179,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
let id = d.alloc_id();
let kind = path_kind(d, ig, rule);
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,
graph: None,
kind,
..n.clone()
})
Arc::new(piece(d, n, ig, rule, it))
})
.collect();
m.appearance.items.clear();
@@ -171,6 +187,75 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Some(m)
}
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
/// paint, or its brush art. `None` when it paints nothing.
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
/// its own), in paint order, as the members of a group that keeps
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
/// effects are dropped. `None` for other kinds of objects.
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
let (g, rule, ctx, mut m) = leaf_base(n)?;
m.appearance.effects.retain(|e| e.visible);
let mut pieces = vec![];
for item in n.appearance.items.iter().filter(|i| paints(i)) {
let ig = apply(item.effects(), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
it.effects_mut().retain(|e| e.visible);
let p = match &it {
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
};
pieces.extend(p.map(hoist_transparency));
}
m.appearance.items.clear();
m.appearance.contents_index = None;
match pieces.as_slice() {
[one] if m.appearance.effects.is_empty() && one.has_default_transparency() && one.appearance.effects.is_empty() => {
m.kind = one.kind.clone();
m.appearance = one.appearance.clone();
}
_ => m.kind = NodeKind::Group { children: pieces.into_iter().map(Arc::new).collect(), clip: false },
}
Some(m)
}
/// `p` with the opacity and blend mode of its only fill or stroke as its own (the same look, and a
/// basic appearance).
fn hoist_transparency(mut p: Node) -> Node {
if p.has_default_transparency()
&& let [item] = p.appearance.items.as_mut_slice()
{
let (opacity, blend) = match item {
AppearanceItem::Fill(f) => (&mut f.opacity, &mut f.blend),
AppearanceItem::Stroke(s) => (&mut s.opacity, &mut s.blend),
};
p.opacity = std::mem::replace(opacity, 1.0);
p.blend = std::mem::take(blend);
}
p
}
/// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a
/// group's own fills and strokes or outlined type), in place.
pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
if let Some(m) = expand_leaf(d, n, stroke_art) {
*n = m;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
expand_art(d, Arc::make_mut(c), stroke_art);
}
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
+1 -1
View File
@@ -141,7 +141,7 @@ fn is_container(n: &Node) -> bool {
}
/// Does `item` paint anything?
fn paints(item: &AppearanceItem) -> bool {
pub(crate) fn paints(item: &AppearanceItem) -> bool {
item.visible()
&& !item.paint().is_none()
&& match item {
+2 -2
View File
@@ -41,13 +41,13 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{bake_document, fresh_ids, needs_bake};
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children,
};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
+18 -2
View File
@@ -113,10 +113,26 @@ fn outline_nested_text(n: &mut Node) {
/// 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 = geometry_effects(n);
if fx.is_empty() || !needs_outline(n) {
if geometry_effects(n).is_empty() || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
}
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
/// when it has neither.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
return None;
}
as_art(n, symbol_art)
}
/// [`reshape`] without its checks (no geometry effects: just the art).
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx = geometry_effects(n);
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);
+67 -12
View File
@@ -148,6 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
target_contents
),
cmd!(
"appearance.transfer",
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}",
has_doc,
transfer
),
]
}
@@ -165,7 +174,7 @@ impl Session {
pub fn appearance_item(&self) -> Option<usize> {
let a = self.active_appearance_item.as_ref()?;
let st = self.active()?;
if st.uid != a.doc || st.selection.objects != a.objects {
if st.uid != a.doc || st.selection.subjects() != a.objects.as_slice() {
return None;
}
let n = st.doc.node(*a.objects.first()?)?;
@@ -230,7 +239,8 @@ impl ItemTarget {
/// leaves (as without an active item) and vice versa. Explicit items are kept (and checked).
pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self {
let ItemTarget::Item { index, explicit: false } = self else { return self };
let fits = s.active().and_then(|st| st.doc.node(*st.selection.objects.first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill));
let fits =
s.active().and_then(|st| st.doc.node(*st.selection.subjects().first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill));
if fits == Some(true) { self } else { ItemTarget::Top }
}
@@ -274,12 +284,22 @@ fn targets_contents(p: &Value) -> Result<bool> {
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the selected
/// objects (a selected compound-path member stands for its compound); with `target: "contents"`
/// the painted objects inside the groups and layers among them ([`leaf_targets`]).
/// The objects appearance edits act on without `ids`: the targeted object, group or layer
/// (`layer.target`), else the selected objects (a selected compound-path member stands for its
/// compound).
pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
match s.doc()?.selection.target {
Some(t) => Ok(vec![t]),
None => selected_roots(s),
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
/// among them ([`leaf_targets`]).
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
selected_roots(s)?
subject_roots(s)?
} else {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
@@ -347,7 +367,7 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
None => return Err(bad(C, "missing `index` (an item index or null)")),
};
let st = s.doc()?;
let objects = st.selection.objects.clone();
let objects = st.selection.subjects().to_vec();
let first = objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let fill = first.appearance.items.get(index).ok_or_else(|| bad(C, format!("no appearance item {index}")))?.is_fill();
let doc = st.uid;
@@ -393,11 +413,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
// No fill, no stroke, and the object's Opacity row back to Default. A group keeps no
// fill or stroke rows of its own.
n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) };
n.opacity = 1.0;
n.blend = BlendMode::Normal;
clear(n);
}
}
Ok(())
@@ -406,6 +422,45 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
/// group keeps no fill or stroke rows of its own.
fn clear(n: &mut Node) {
n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) };
n.opacity = 1.0;
n.blend = BlendMode::Normal;
}
fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.transfer";
let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = bool_or(p, "copy", false);
s.edit(if copy { "Copy Appearance" } else { "Move Appearance" }, |d, _| {
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
let mut ap = src.appearance.clone();
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
ap.rebase_gradients(f, t);
}
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
if to.contents_label().is_none() {
ap.contents_index = None;
}
to.appearance = ap;
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
if !copy && let Some(n) = d.node_mut(source) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
}
Ok(())
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
}
/// A group or layer (no fill or stroke rows of its own unless added).
fn is_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
+185 -53
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Effect, Node, NodeKind};
use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
@@ -58,8 +58,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?: [..], target?} bake geometry effects into the paths and drop them (raster effects stay live); a group's or layer's own fills and strokes become paths among its members → {ids}",
has_doc,
"{ids?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
can_expand,
expand_appearance
),
cmd!(
@@ -247,71 +247,202 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Geometry of a path or compound path node.
fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
// ---------- Expand Appearance ----------
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
/// fills or strokes of its own?
fn expandable(n: &Node) -> bool {
match &n.kind {
NodeKind::Path { path, rule, guide: false, .. } => 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))
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
}
_ => None,
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
_ => !n.appearance.effects.is_empty(),
}
}
/// Bake the object-level geometry effects of `id` (and, recursively, of group members). A group's
/// or layer's Pathfinder and geometry effects and its own fills and strokes become its members.
fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
if let Some(mut m) = effects::evaluate_container(&n) {
let mut seen = std::collections::HashSet::from([m.id]);
for c in m.children_mut().into_iter().flatten() {
effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
}
let Some(slot) = d.node_mut(id) else { return };
*slot = m;
out.push(id);
fn can_expand(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let d = &s.doc().map_err(|e| e.to_string())?.doc;
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
Ok(())
} else {
Err("select (or target) objects whose appearance isn't basic".into())
}
}
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
/// everything inside it.
fn strip_raster(n: &mut Node, members: bool) {
let keep = |e: &Effect| !effects::is_raster(&e.id);
n.appearance.effects.retain(keep);
for it in &mut n.appearance.items {
it.effects_mut().retain(keep);
}
if members {
for c in n.children_mut().into_iter().flatten() {
strip_raster(Arc::make_mut(c), true);
}
}
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at
/// the document's raster effects resolution, with whether they all paint below it (shadows and
/// outer glows: the image then holds just them). A group's or layer's image leaves out its
/// members' raster effects when those are expanded with them. `None` when it has none.
fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
let fx: Vec<effects::RasterFx> = std::iter::once(&n.appearance.effects)
.chain(n.appearance.items.iter().map(|i| i.effects()))
.flat_map(|e| effects::raster_effects(e))
.collect();
if fx.is_empty() {
return None;
}
let below = fx.iter().all(effects::RasterFx::is_below);
// Its transparency stays on the expanded object.
let mut whole = n.clone();
(whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None);
if below && is_container(&whole) {
for c in whole.children_mut().into_iter().flatten() {
strip_raster(Arc::make_mut(c), true);
}
}
let bare = below.then(|| {
let mut b = whole.clone();
strip_raster(&mut b, false);
b
});
let scale = super::rasterfx::effects_scale(d);
let image = super::rasterfx::effect_image(d, &whole, bare.as_ref(), scale)?;
Some((image, below))
}
/// `m` (an expanded object) with `image` of its shadows and outer glows painted below its art: the
/// image goes first in a group or layer (after a layer's clipping path), anything else is grouped
/// with it under its id, name and transparency.
fn put_below(d: &mut Document, m: &mut Node, image: Node) {
match &mut m.kind {
NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)),
NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)),
_ => {
let mut inner = m.clone();
inner.id = d.alloc_id();
(inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None);
inner.knockout = Default::default();
inner.knockout_shape = false;
m.appearance = Default::default();
m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false };
}
}
}
/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that
/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with
/// `all` every member.
fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) {
let id = m.id;
let mut seen = std::collections::HashSet::from([id]);
for c in m.children_mut().into_iter().flatten() {
let own = all || c.id == id;
let c = Arc::make_mut(c);
effects::fresh_ids(d, c, &mut seen);
if own {
effects::expand_art(d, c, stroke_art);
}
}
}
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
let container = is_container(&n);
if !expandable(&n) {
return;
}
let image = raster_image(d, &n);
let mut v = n.clone();
strip_raster(&mut v, false);
let mut stroke_art =
|d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st);
let expanded = match image {
// Blur, feather and inner glow change the object itself: it becomes the image (a layer
// keeps it as its only member).
Some((image, false)) => Some(if n.is_layer() {
let mut m = v;
m.appearance = Default::default();
m.set_clips(false);
if let Some(ch) = m.children_mut() {
*ch = vec![Arc::new(image)];
}
m
} else {
Node { kind: image.kind, appearance: Default::default(), ..v }
}),
image => {
let m = if container {
// A group's or layer's own fills and strokes become art among its members.
effects::evaluate_container(&v).map(|mut m| {
expand_pieces(d, &mut m, false, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
effects::expand_leaf(d, &v, &mut stroke_art)
} else {
// Type, images, symbol instances, live objects: through their outlines.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
expand_pieces(d, &mut m, true, &mut stroke_art);
m
})
};
match image {
Some((image, _)) => {
let mut m = m.unwrap_or(v);
put_below(d, &mut m, image);
Some(m)
}
None => m,
}
}
};
if let Some(m) = expanded
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
out.push(id);
}
if container {
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
for c in children {
expand_node(d, c, out);
expand_node(d, c, brushes, out);
}
return;
}
if !effects::has_geometry(&n.appearance.effects) {
return;
}
let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return };
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n));
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
let children = baked
.subpaths
.into_iter()
.map(|sp| {
let cid = d.alloc_id();
Arc::new(Node::path(cid, PathData::single(sp), Default::default()))
})
.collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path: baked, rule, live: None, clipping: false, guide: false }
};
let Some(m) = d.node_mut(id) else { return };
m.kind = kind;
m.appearance.effects.retain(|e| !effects::is_geometry(&e.id));
out.push(id);
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let ids = s.edit("Expand Appearance", |d, _| {
let brushes = vectorcraft_brush::library(d);
let mut out = vec![];
for id in &roots {
expand_node(d, *id, &mut out);
expand_node(d, *id, &brushes, &mut out);
}
if out.is_empty() {
return Err(EngineError::Other("Expand Appearance: no geometry effects to expand".into()));
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
}
Ok(out)
})?;
@@ -370,16 +501,16 @@ mod tests {
}
#[test]
fn expand_appearance_bakes_geometry_and_keeps_raster() {
fn expand_appearance_bakes_geometry() {
let (mut s, id) = session_with_rect();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
// One fill: the path itself, reshaped.
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.1, "{b:?}");
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].id, "stylize.dropShadow");
assert!(n.appearance.effects.is_empty());
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }));
// Nothing left to expand.
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
@@ -388,6 +519,7 @@ mod tests {
#[test]
fn expand_transform_copies_makes_compound() {
let (mut s, id) = session_with_rect();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
+27
View File
@@ -56,9 +56,36 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
clip_toggle
),
cmd!(
"layer.target",
"Target",
[],
None,
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
has_doc,
target
),
]
}
/// The Layers panel's target circle.
fn target(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.target";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
if !st.doc.is_editable(id) {
return Err(bad(C, "the object is hidden or locked"));
}
let layer = n.is_layer();
s.select(|d, sel| sel.set_target(d, id))?;
let st = s.doc_mut()?;
if layer {
st.active_layer = Some(id);
}
Ok(json!({ "id": id.0, "selected": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// The Layers panel's clipping mask button: the top object of a layer (or group) clips the rest,
/// or the clipping mask is released.
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -75,7 +75,7 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "already editing an opacity mask"));
}
let id = id_param(p, "id")
.or_else(|| st.selection.objects.iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())))
.or_else(|| st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())))
.ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?;
show_editing(s, Some(layer))?;
@@ -117,7 +117,7 @@ pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
sync(d);
d.drop_edit_modes();
if d.node(me.object).is_some() {
sel.set([me.object]);
sel.set_target(d, me.object);
} else {
sel.clear();
}
+11 -7
View File
@@ -12,7 +12,6 @@ use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
use super::edit::selected_roots;
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
use super::*;
use crate::EngineError;
@@ -134,12 +133,17 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// The objects the Transparency panel's commands act on: `ids` / `id`, else the object whose
/// opacity mask is being edited (the selection then is its mask art), else the selection.
/// opacity mask is being edited (the selection then is its mask art), else the targeted object,
/// group or layer (`layer.target`), else the selection.
pub(crate) fn transparency_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match s.doc()?.doc.mask_edit {
Some(me) if p.get("ids").is_none() && p.get("id").is_none() => Ok(vec![me.object]),
_ => targets(s, p),
let st = s.doc()?;
if p.get("ids").is_some() || p.get("id").is_some() {
return targets(s, p);
}
Ok(match st.doc.mask_edit {
Some(me) => vec![me.object],
None => st.selection.subjects().to_vec(),
})
}
/// An opacity parameter, in percent (0..100) everywhere, as the model's 0..1.
@@ -170,7 +174,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
}
Selection { objects: ids, ..Default::default() }.in_paint_order(d)
}
None => selected_roots(s)?,
None => super::appearance::subject_roots(s)?,
};
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
@@ -202,7 +206,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
let mut m = OpacityMask::new(mask_art, clip);
m.invert = invert;
n.mask = Some(Box::new(m));
sel.set([target]);
sel.set_target(d, target);
let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None };
Ok((target, layer))
})?;
+1 -1
View File
@@ -173,7 +173,7 @@ impl Session {
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art.
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option<usize>)> {
let st = self.active()?;
let first = st.doc.node(*st.selection.objects.first()?)?;
let first = st.doc.node(*st.selection.subjects().first()?)?;
let item = self.appearance_item();
if first.appearance.item_of_kind(item, !stroke).is_some() {
return Some((first, item));
+7 -1
View File
@@ -460,7 +460,13 @@ fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMo
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
/// and effects. `None` when it paints nothing.
fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, st: &StrokeLayer) -> Option<Node> {
pub(crate) fn outlined_stroke(
d: &mut Document,
brushes: &[vectorcraft_brush::Brush],
path: &PathData,
rule: FillRule,
st: &StrokeLayer,
) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let art = match pieces.len() {
+51 -28
View File
@@ -73,42 +73,65 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64) -> vectorc
r.render_region(&tmp, region, scale, false)
}
/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`.
fn flatten_node(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
let fx = raster_fx(n);
let b = n.visual_bounds()?;
let reach = effects::outset(&n.appearance.effects) + 2.0;
/// Pixels per point of raster effects rendered as images (the document's raster effects
/// resolution).
pub(crate) fn effects_scale(doc: &Document) -> f64 {
(doc.raster_effects_ppi / 72.0).clamp(1.0 / 72.0, 2400.0 / 72.0)
}
/// An embedded image (a new node of `out`, its pixels in `out.images`) of `whole` rendered alone
/// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects
/// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out
/// of the image, which then only holds what the effects add around it (a shadow to go under the
/// vector object).
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
let b = whole.visual_bounds()?;
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) + 2.0;
let b = b.inflate(reach, reach);
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
let below_only = fx.iter().all(RasterFx::is_below);
let mut whole = n.clone();
whole.blend = vectorcraft_doc::color::BlendMode::Normal;
let mut img = render(src, vec![whole.clone()], region, scale);
let mut vector = None;
if below_only {
// Knock the object's own coverage out of the image; the vector object goes on top.
let mut bare = whole.clone();
bare.appearance.effects.retain(|e| !effects::is_raster(&e.id));
bare.opacity = 1.0;
let obj = render(src, vec![bare], region, scale);
let mut img = render(out, vec![whole.clone()], region, scale);
if let Some(bare) = knockout {
let obj = render(out, vec![bare.clone()], region, scale);
for (px, o) in img.pixels.chunks_exact_mut(4).zip(obj.pixels.chunks_exact(4)) {
let keep = 1.0 - o[3] as f32 / 255.0;
for c in px.iter_mut() {
*c = (*c as f32 * keep).round() as u8;
}
}
}
let id = out.alloc_id();
let mut key = format!("raster-effect-{}", id.0);
while out.images.contains_key(&key) {
key.push('+');
}
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(img.to_png()) });
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
image.name = Some("Raster effect".into());
Some(image)
}
/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`.
fn flatten_node(out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
let below_only = raster_fx(n).iter().all(RasterFx::is_below);
let mut whole = n.clone();
whole.blend = vectorcraft_doc::color::BlendMode::Normal;
// Shadows only: knock the object's own coverage out of the image; the vector object goes on top.
let bare = below_only.then(|| {
let mut bare = whole.clone();
bare.appearance.effects.retain(|e| !effects::is_raster(&e.id));
bare.opacity = 1.0;
bare
});
let mut image = effect_image(out, &whole, bare.as_ref(), scale)?;
let vector = bare.map(|_| {
let mut v = n.clone();
v.appearance.effects.retain(|e| !effects::is_raster(&e.id));
v.blend = vectorcraft_doc::color::BlendMode::Normal;
vector = Some(v);
}
let key = format!("raster-effect-{}", n.id.0);
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(img.to_png()) });
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(out.alloc_id(), NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
image.name = Some("Raster effect".into());
v
});
Some(match vector {
Some(v) => {
let mut g = Node::group(out.alloc_id(), vec![Arc::new(image), Arc::new(v)]);
@@ -123,15 +146,15 @@ fn flatten_node(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Opt
})
}
fn walk(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
fn walk(out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
if !needs(n) || !n.visible {
return None;
}
if !raster_fx(n).is_empty() {
return flatten_node(src, out, n, scale);
return flatten_node(out, n, scale);
}
let mut m = n.clone();
let ch: Vec<Arc<Node>> = n.children()?.iter().map(|c| walk(src, out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
let ch: Vec<Arc<Node>> = n.children()?.iter().map(|c| walk(out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
if let Some(slot) = m.children_mut() {
*slot = ch;
}
@@ -147,10 +170,10 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
// Geometry effects first, so the vector objects kept above shadows are final.
let baked = effects::bake_document(doc);
let src = baked.as_ref().unwrap_or(doc);
let scale = (src.raster_effects_ppi / 72.0).clamp(1.0 / 72.0, 2400.0 / 72.0);
let scale = effects_scale(src);
let mut out = src.clone();
let layers = src.layers.clone();
out.layers = layers.iter().map(|l| walk(src, &mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
out.layers = layers.iter().map(|l| walk(&mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
Some(out)
}
+1 -1
View File
@@ -302,7 +302,7 @@ impl Session {
/// it is still linked (still has that style's look).
pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> {
let st = self.active()?;
let n = st.doc.node(*st.selection.objects.first()?)?;
let n = st.doc.node(*st.selection.subjects().first()?)?;
let g = st.doc.graphic_style_by_id(n.graphic_style?)?;
Some((g, in_sync(n, g)))
}
+2
View File
@@ -64,6 +64,8 @@ pub fn document(s: &Session) -> Value {
"isolation": st.isolation.map(|l| l.0),
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
// The layer, group or object targeted through the Layers panel (`layer.target`).
"target": st.selection.target.map(|t| t.0),
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
+4
View File
@@ -787,6 +787,8 @@ mod tests_editcolors;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_expand;
#[cfg(test)]
mod tests_eyedropper;
#[cfg(test)]
mod tests_file;
@@ -839,6 +841,8 @@ mod tests_swatches;
#[cfg(test)]
mod tests_swatchlib;
#[cfg(test)]
mod tests_targeting;
#[cfg(test)]
mod tests_textedit;
#[cfg(test)]
mod tests_tileedge;
+7 -3
View File
@@ -140,8 +140,12 @@ fn group_appearance_round_trips_and_expands() {
assert!(n.appearance.items.is_empty() && n.appearance.effects.is_empty());
let ch = n.children().unwrap();
assert_eq!(ch.len(), 4, "fill, two members, stroke");
// Each is a group of one path per member (sharing the item's opacity and blend mode).
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
// stroke's paths are its outlines, filled.
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2);
assert!((n.geometric_bounds().unwrap().y0 - 70.0).abs() < 1e-6);
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
}
+192
View File
@@ -0,0 +1,192 @@
//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster
//! effects as images, brushes as art, and the enable rule.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Node, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke.
fn square(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"color": "#ff0000"}));
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
run(s, "stroke.set", json!({"weight": 4}));
id
}
fn render(s: &Session) -> vectorcraft_render::Rendered {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
}
fn expandable(s: &Session) -> bool {
s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
let id = square(&mut s);
assert!(!expandable(&s), "a basic appearance has nothing to expand");
// A second fill on top (half-transparent green) and an outset stroke.
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1}));
assert!(expandable(&s));
let before = render(&s);
let r = run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(r["ids"], json!([id.0]));
let n = node(&s, id);
let ch = n.children().expect("a group of the fills and the stroke");
assert_eq!(ch.len(), 3);
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
let ring = ch[1].geometric_bounds().unwrap();
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
assert!(ch[2].appearance.is_basic());
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
assert!(!expandable(&s));
// One undo step.
run(&mut s, "edit.undo", json!({}));
assert!(matches!(node(&s, id).kind, NodeKind::Path { .. }));
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
#[test]
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
let mut s = session();
let id = square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}}));
run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"}));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply);
assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty());
let ch = n.children().unwrap();
assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke");
let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) };
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty()));
let after = render(&s);
assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24));
// The shadow is still there, below and right of the square.
let p = after.pixel(155, 155);
assert!(p[0] < 200, "{p:?}");
}
#[test]
fn a_blur_turns_the_object_into_an_image() {
let mut s = session();
let id = square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}}));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label());
assert!(n.appearance.effects.is_empty());
}
#[test]
fn an_art_brush_becomes_a_group_of_its_art() {
let mut s = session();
let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.setFill", json!({"none": true}));
run(&mut s, "brush.apply", json!({"name": "Charcoal"}));
assert!(expandable(&s));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
let mut brushed = false;
n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c));
assert!(!brushed, "no brush strokes are left");
let after = render(&s);
assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24));
run(&mut s, "edit.undo", json!({}));
assert!(vectorcraft_brush::has_brush(&node(&s, id)));
}
#[test]
fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
let mut s = session();
let a = square(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.target", json!({"id": layer.0}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "stroke.set", json!({"item": 0, "weight": 6}));
assert!(expandable(&s));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let l = node(&s, layer);
assert!(l.appearance.items.is_empty());
let ch = l.children().unwrap();
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
assert_eq!(ch[0].id, a);
// The layer's stroke is outlined: a filled path.
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
}
#[test]
fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, t);
let ch = n.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_)));
// A stroke of its own: outlined glyphs.
let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [u.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "effect.expandAppearance", json!({}));
let mut text = false;
node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
assert!(!text, "the type is outlined");
assert!(!expandable(&s));
}
#[test]
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
let mut s = session();
square(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
run(&mut s, "layer.target", json!({"id": layer.0}));
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"}));
run(&mut s, "effect.expandAppearance", json!({}));
let l = node(&s, layer);
assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty());
let ch = l.children().unwrap();
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
}
+117
View File
@@ -0,0 +1,117 @@
//! Targeting through the Layers panel's target circles (`layer.target`) and moving or copying
//! appearances between objects (`appearance.transfer`).
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::Node;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn rect(s: &mut Session, x: f64) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap())
}
#[test]
fn a_targeted_layer_takes_transparency_appearance_and_effects() {
let mut s = session();
let a = rect(&mut s, 20.0);
let b = rect(&mut s, 100.0);
let layer = s.doc().unwrap().doc.layers[0].id;
let r = run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(r["selected"], json!([a.0, b.0]), "the layer's art is selected");
assert_eq!(s.doc().unwrap().selection.target, Some(layer));
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
let l = node(&s, layer);
assert!((l.opacity - 0.4).abs() < 1e-6 && l.blend == BlendMode::Multiply);
assert_eq!(node(&s, a).opacity, 1.0, "the art keeps its own opacity");
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["ids"], json!([layer.0]));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
let l = node(&s, layer);
assert_eq!(l.appearance.items.len(), 1);
assert_eq!(l.appearance.effects.len(), 1);
// The Appearance panel's active row is a row of the layer's own stack.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
assert_eq!(s.appearance_item(), Some(0));
// Undo is one step per command, and any other selection change ends the targeting.
run(&mut s, "edit.undo", json!({}));
assert!(node(&s, layer).appearance.effects.is_empty());
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(s.doc().unwrap().selection.target, None);
assert_eq!(s.appearance_item(), None);
run(&mut s, "transparency.set", json!({"opacity": 50}));
assert!((node(&s, a).opacity - 0.5).abs() < 1e-6);
assert!((node(&s, layer).opacity - 0.4).abs() < 1e-6);
}
#[test]
fn targeting_an_object_selects_it_and_locked_layers_cannot_be_targeted() {
let mut s = session();
let a = rect(&mut s, 20.0);
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.target", json!({"id": a.0}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
assert!(s.execute("layer.target", &json!({"id": layer.0})).is_err());
assert!(s.execute("layer.target", &json!({"id": 9999})).is_err());
}
#[test]
fn a_targeted_layer_gets_an_opacity_mask() {
let mut s = session();
rect(&mut s, 20.0);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0));
run(&mut s, "transparency.stopEditingOpacityMask", json!({}));
assert!(node(&s, layer).mask.is_some());
assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted");
}
#[test]
fn transfer_moves_or_copies_the_appearance() {
let mut s = session();
let a = rect(&mut s, 20.0);
let b = rect(&mut s, 100.0);
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "transparency.set", json!({"opacity": 30, "blend": "screen"}));
let src = node(&s, a);
// Copy (Alt-drag): both have it.
run(&mut s, "appearance.transfer", json!({"source": a.0, "target": b.0, "copy": true}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(na.appearance, src.appearance);
assert_eq!(nb.appearance, src.appearance);
assert!((nb.opacity - 0.3).abs() < 1e-6 && nb.blend == BlendMode::Screen);
// Move onto the layer: the source is left cleared; one undo step brings it back.
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "appearance.transfer", json!({"source": a.0, "target": layer.0}));
let (na, nl) = (node(&s, a), node(&s, layer));
assert_eq!(nl.appearance.items.len(), src.appearance.items.len());
assert_eq!(nl.appearance.effects.len(), 1);
assert!((nl.opacity - 0.3).abs() < 1e-6);
assert!(na.appearance.effects.is_empty() && na.opacity == 1.0 && na.blend == BlendMode::Normal);
assert_eq!(na.appearance.fill_paint(), Paint::None);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, a).appearance, src.appearance);
assert!(node(&s, layer).appearance.items.is_empty());
assert!(s.execute("appearance.transfer", &json!({"source": a.0, "target": a.0})).is_err());
assert!(s.execute("appearance.transfer", &json!({"source": a.0})).is_err());
}
+32 -3
View File
@@ -195,6 +195,18 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
"<Opacity Mask>/Opacity Mask".to_string()
} else {
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
format!("{color}/{}", if outline { "Outline" } else { "Preview" })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
}
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)".
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -207,9 +219,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let active = app.session.active_index();
for (i, d) in app.session.documents().iter().enumerate() {
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
let mode = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
let vm = if app.ui.view.outline { "Outline" } else { "Preview" };
let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"));
let title = tab_title(d, zoom, app.ui.view.outline);
let is_active = Some(i) == active;
let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim });
let w = galley.size().x + 50.0;
@@ -467,3 +477,22 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
#[test]
fn the_tab_title_names_the_opacity_mask_while_it_is_edited() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false);
assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s));
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert!(title(&s).ends_with("(<Opacity Mask>/Opacity Mask)"), "{}", title(&s));
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert!(title(&s).ends_with("(RGB/Preview)"));
}
}
+14 -7
View File
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId};
use super::{first_selected, live_run, pstate, set_pstate};
use super::{live_run, pstate, set_pstate};
use crate::panels::stroke::paint_profile;
use crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, TransparencyEdit, menu_item};
@@ -86,7 +86,7 @@ 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());
let owner = app.session.active().and_then(|d| d.selection.subjects().first().copied());
set_pstate(ctx, "ap-sel", (owner, sel));
set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::<Vec<usize>>()));
}
@@ -223,7 +223,7 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let node = first_selected(app);
let node = first_subject(app);
let sel = current_sel(app, ui.ctx(), node.as_ref());
let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb");
widgets::list_box(ui, |ui| {
@@ -272,11 +272,18 @@ fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) {
}
}
/// The object the panel lists: the targeted layer, group or object (`layer.target`), else the
/// first selected one.
fn first_subject(app: &VectorcraftApp) -> Option<Node> {
let st = app.session.active()?;
st.selection.subjects().first().and_then(|id| st.doc.node(*id)).cloned()
}
/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects"
/// or the object's kind ("Path", "Type", …).
/// or the kind ("Path", "Type", "Layer"…) of the object, or targeted layer, the panel lists.
pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
let Some(st) = app.session.active() else { return "No Selection" };
match st.selection.objects.as_slice() {
match st.selection.subjects() {
[] => "No Selection",
[id] => st.doc.node(*id).map_or("No Selection", Node::kind_label),
_ => "Mixed Objects",
@@ -286,7 +293,7 @@ pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
/// Do the selected objects differ in appearance (fills, strokes, effects, opacity or blend mode)?
fn mixed_appearances(app: &VectorcraftApp) -> bool {
let Some(st) = app.session.active() else { return false };
let mut nodes = st.selection.objects.iter().filter_map(|id| st.doc.node(*id));
let mut nodes = st.selection.subjects().iter().filter_map(|id| st.doc.node(*id));
let Some(first) = nodes.next() else { return false };
nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend)
}
@@ -839,7 +846,7 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let node = first_selected(app);
let node = first_subject(app);
let has = node.is_some();
let sel = current_sel(app, ui.ctx(), node.as_ref());
if menu_item(ui, "Add New Fill", has, false) {
+191 -18
View File
@@ -1,5 +1,10 @@
//! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target
//! circles and selection squares; bottom bar with new/delete.
//!
//! A target circle targets its layer, group or object (`layer.target`); dragging it onto another
//! row's circle moves the appearance there (Alt copies it, `appearance.transfer`) and dropping it
//! on the trash clears it. While an opacity mask is edited the panel lists only its art, under
//! an `<Opacity Mask>` entry.
use std::collections::HashSet;
@@ -8,6 +13,7 @@ use serde_json::json;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use crate::theme::Tokens;
use crate::widgets::PanelDrag;
use crate::{VectorcraftApp, icons, widgets};
const ROW: f32 = 26.0;
@@ -21,7 +27,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let sel: HashSet<NodeId> = st.selection.objects.iter().copied().collect();
let target = st.selection.target;
let current = st.active_layer;
// While an opacity mask is edited only its art is listed.
let mask_layer = doc.mask_edit.map(|m| m.layer);
let mut expanded: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect());
let mut actions: Vec<(String, serde_json::Value)> = vec![];
// Search field ("Search All").
@@ -30,8 +39,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let h = ui.available_height() - 34.0;
egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for l in doc.layers.iter().rev() {
row(ui, &doc, l, 0, false, &sel, current, &mut expanded, &mut actions, &t);
for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) {
row(ui, &doc, l, 0, false, &sel, target, current, &mut expanded, &mut actions, &t);
}
});
ui.data_mut(|d| d.insert_temp(expanded_id(), expanded));
@@ -50,7 +59,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
t.text_dim,
);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() {
let trash = widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0);
// A target circle dropped on the trash clears that appearance.
if let Some(d) = trash.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(id) = *d
{
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
} else if trash.clicked() {
if app.session.active().is_some_and(|d| !d.selection.is_empty()) {
actions.push(("edit.clear".into(), json!({})));
} else {
@@ -91,6 +106,7 @@ fn row(
depth: usize,
clip_path: bool,
sel: &HashSet<NodeId>,
target: Option<NodeId>,
current: Option<NodeId>,
expanded: &mut HashSet<u64>,
actions: &mut Vec<(String, serde_json::Value)>,
@@ -177,7 +193,7 @@ fn row(
}
x += 26.0;
// Name.
let name = n.display_name();
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.display_name() };
let font = egui::FontId::proportional(13.0);
let rename_id = egui::Id::new("layers-rename");
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id));
@@ -202,9 +218,11 @@ fn row(
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text);
// A clipping path's name is underlined.
// A clipping path's name is underlined, a masked object's with a dashed line.
if clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
}
}
@@ -212,14 +230,7 @@ fn row(
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
let tc = egui::pos2(r.right() - 28.0, r.center().y);
let styled = has_styled_target(n);
ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon));
if is_sel {
ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon));
}
if styled {
ui.painter().circle_filled(tc, 3.2, t.icon);
}
let tresp = target_circle(ui, n, tc, target.map_or(is_sel, |id| id == n.id), actions, t);
let sq = egui::pos2(r.right() - 11.0, r.center().y);
if is_sel {
let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0));
@@ -229,7 +240,9 @@ fn row(
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color);
}
let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, ROW)), ui.id().with(("selsq", n.id.0)), Sense::click());
if sq_resp.clicked() {
if tresp.clicked() {
actions.push(("layer.target".into(), json!({"id": n.id.0})));
} else if sq_resp.clicked() {
if n.is_layer() {
actions.push(("layer.selectAll".into(), json!({"id": n.id.0})));
} else if ui.input(|i| i.modifiers.shift) {
@@ -276,15 +289,47 @@ fn row(
if has_children && expanded.contains(&n.id.0) {
for (i, c) in n.children().unwrap().iter().enumerate().rev() {
row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, current, expanded, actions, t);
row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, target, current, expanded, actions, t);
}
}
}
/// Whether an object's target circle is filled: its appearance is not basic or its transparency
/// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default.
/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n`
/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging
/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt
/// copies it) or the trash.
fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, targeted: bool, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) -> egui::Response {
let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, ROW)), ui.id().with(("target", n.id.0)), Sense::click_and_drag());
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id));
let held = resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id));
if let Some(d) = resp.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(source) = *d
&& source != n.id
{
let copy = ui.input(|i| i.modifiers.alt);
actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy})));
}
let ring = if held { t.accent } else { t.icon };
ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring));
if targeted {
ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon));
}
if has_styled_target(n) {
ui.painter().circle_filled(c, 3.2, t.icon);
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
resp
}
/// Whether an object's target circle is filled: its appearance is not basic (a group or layer:
/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode,
/// isolation, knockout or an opacity mask) is not the default.
fn has_styled_target(n: &Node) -> bool {
!n.appearance.is_basic() || !n.has_default_transparency()
!n.appearance.is_basic()
|| (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty())
|| !n.has_default_transparency()
}
/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc`
@@ -403,4 +448,132 @@ mod tests {
run(&mut app, "layer.clippingMask.toggle", json!({}));
assert_eq!(underlines(&mut app), 0);
}
/// One headless frame of the panel with `events` (Alt held when `alt`): the centres of the
/// target circles, top row first, and the text-coloured line segments (underlines) drawn.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, alt: bool) -> (Vec<egui::Pos2>, usize) {
let mut events = events;
events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() }));
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let text = Tokens::get(ctx).text;
let mut circles = vec![];
let mut lines = 0;
for c in &out.shapes {
match &c.shape {
egui::Shape::Circle(cs) if cs.radius == 5.0 => circles.push(cs.center),
egui::Shape::LineSegment { stroke, .. } if stroke.color == text => lines += 1,
_ => {}
}
}
circles.sort_by(|a, b| a.y.total_cmp(&b.y));
(circles, lines)
}
fn button(pos: egui::Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// Drag from `a` to `b` over a few frames (Alt held when `alt`).
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) {
let steps = [
vec![egui::Event::PointerMoved(a)],
vec![button(a, true)],
vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))],
vec![egui::Event::PointerMoved(b)],
vec![button(b, false)],
vec![],
];
for e in steps {
frame(app, ctx, e, alt);
}
}
/// A document with two rectangles on one layer → (app, layer, [bottom, top]).
fn two_rects() -> (VectorcraftApp, NodeId, [NodeId; 2]) {
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": 200, "height": 200}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(&mut app, "select.none", json!({}));
let layer = app.session.active().unwrap().doc.layers[0].id;
(app, layer, [NodeId(a), NodeId(b)])
}
#[test]
fn clicking_a_target_circle_targets_the_layer() {
let (mut app, layer, [a, b]) = two_rects();
let ctx = egui::Context::default();
// Rows top down: the layer, the top rectangle, the bottom one.
let (c, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 3);
for e in [egui::Event::PointerMoved(c[0]), button(c[0], true), button(c[0], false)] {
frame(&mut app, &ctx, vec![e], false);
}
let st = app.session.active().unwrap();
assert_eq!(st.selection.target, Some(layer));
assert_eq!(st.selection.objects, vec![a, b]);
assert_eq!(super::super::appearance::object_label(&app), "Layer", "the Appearance panel lists the layer");
app.session.execute("transparency.set", &json!({"opacity": 25})).unwrap();
assert!((app.session.active().unwrap().doc.node(layer).unwrap().opacity - 0.25).abs() < 1e-6);
// The layer's circle is filled now; clicking an object's circle targets that object.
assert_eq!(filled_targets(&mut app, &ctx), 1);
for e in [egui::Event::PointerMoved(c[2]), button(c[2], true), button(c[2], false)] {
frame(&mut app, &ctx, vec![e], false);
}
let st = app.session.active().unwrap();
assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a]));
}
#[test]
fn dragging_a_target_circle_moves_or_copies_the_appearance_and_the_trash_clears_it() {
let (mut app, layer, [a, b]) = two_rects();
app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap();
let ctx = egui::Context::default();
let (c, _) = frame(&mut app, &ctx, vec![], false);
let opacity = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().opacity;
// Alt-drag the bottom rectangle's circle onto the top one's: both have it.
drag(&mut app, &ctx, c[2], c[1], true);
assert!((opacity(&app, b) - 0.3).abs() < 1e-6 && (opacity(&app, a) - 0.3).abs() < 1e-6);
// A plain drag onto the layer's circle moves it there.
drag(&mut app, &ctx, c[1], c[0], false);
assert!((opacity(&app, layer) - 0.3).abs() < 1e-6 && opacity(&app, b) == 1.0);
// Dropped on the trash, the layer's appearance is cleared (and nothing is deleted).
let st = app.session.active().unwrap();
let count = st.doc.node_count();
let trash = {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
out.textures_delta.clear();
// The bottom bar's rightmost button, under the right end of its top divider.
let divider = Tokens::get(&ctx).divider;
let corner = out.shapes.iter().rev().find_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
_ => None,
});
corner.unwrap() + vec2(-12.0, 15.0)
};
drag(&mut app, &ctx, c[0], trash, false);
let st = app.session.active().unwrap();
assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0);
assert_eq!(st.doc.node_count(), count);
}
#[test]
fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() {
let (mut app, _, [a, _]) = two_rects();
let ctx = egui::Context::default();
let (_, before) = frame(&mut app, &ctx, vec![], false);
app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap();
app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap();
// Editing the new (empty) mask: one row, `<Opacity Mask>`.
let (c, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 1);
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
assert!(texts.iter().any(|(t, _)| t == "<Opacity Mask>"), "{texts:?}");
app.session.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
let (c, after) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 3);
assert!(after > before + 1, "a dashed underline: {before} → {after}");
}
}
+32
View File
@@ -396,3 +396,35 @@ branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its
{"name":"run_command","arguments":{"command":"eyedropper.setOptions","params":{"pickUp":{"appearance":{"stroke":{"weight":false}}}}}}
{"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}}
```
## Targeting layers and moving appearances
`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art selected and is itself
the target, so `appearance.*`, `effect.*`, `transparency.*` and the opacity-mask commands without `ids` act on the
layer (its opacity, its own fills and effects, an opacity mask on the whole layer); a group or object is simply
selected. `document.inspect` reports it as `target`, and any other selection change ends it.
`appearance.transfer {source, target, copy?}` is dragging a target circle onto another: the target gets the source's
fills, strokes, effects and transparency, and the source is cleared unless `copy` (Alt-drag). Dropping a circle on the
panel's trash is `appearance.clear {ids: [id]}`. Masked objects' names have a dashed underline; while an opacity mask
is edited the panel lists only an `<Opacity Mask>` entry and the document tab says `(<Opacity Mask>/Opacity Mask)`.
```json
{"name":"run_command","arguments":{"command":"layer.target","params":{"id":2}}}
{"name":"run_command","arguments":{"command":"transparency.set","params":{"opacity":50}}}
{"name":"run_command","arguments":{"command":"appearance.transfer","params":{"source":12,"target":2,"copy":true}}}
```
## Expand Appearance
`effect.expandAppearance {ids?, target?}` (Object → Expand Appearance, enabled when a selected or targeted object's appearance isn't basic)
turns appearances into plain objects in one undo step: each visible fill becomes a copy of the path painted by that
fill alone and each stroke its outline filled with its paint (a brushed stroke: its brush art), grouped in paint order
under the object's id; each piece takes its fill's or stroke's opacity and blend mode, and the group keeps the
object's transparency and opacity mask. Geometry effects are baked; raster effects become an embedded image at the
document's raster effects resolution (shadows and outer glows under the art; a blur, feather or inner glow replaces
the object with the image). Type with effects or fills and strokes of its own is outlined. A group's or layer's own
fills and strokes become objects among its members, and its members are expanded too.
```json
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}}
```