M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows

A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 00:59:34 +07:00
co-authored by Claude Opus 5.5
parent 25c8f32fdd
commit 8a837fd4b9
35 changed files with 1282 additions and 245 deletions
+147 -53
View File
@@ -8,6 +8,14 @@
//! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means
//! the topmost one. A targeted item lives in the selected objects' own stacks (a group's own fill,
//! not its contents'), so item edits apply to the selected objects themselves.
//!
//! **Object or contents.** The `appearance.*` and `effect.*` commands edit the stacks of the
//! selected objects themselves (`target: "object"`, the default; with `ids`, layers too) or, with
//! `target: "contents"`, of the painted objects inside the selected groups and layers. A group's or
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
//! paint below the members and which above ([`Appearance::contents_at`], moved by
//! `appearance.moveItem {from: "contents"}`).
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
@@ -26,8 +34,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
None,
"{ids?} add a fill on top of each selected object's own stack",
has_selection,
"{ids?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
has_doc,
|s, p| add_item(s, p, true)
),
cmd!(
@@ -35,8 +43,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
None,
"{ids?} add a stroke on top of each selected object's own stack",
has_selection,
"{ids?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
has_doc,
|s, p| add_item(s, p, false)
),
cmd!(
@@ -44,8 +52,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
has_selection,
"{ids?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
has_doc,
clear_appearance
),
cmd!(
@@ -53,8 +61,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
has_selection,
"{ids?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
has_doc,
reduce_basic
),
cmd!(
@@ -62,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids? (default: the selection), opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
"{index: paint-order item index, ids? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
has_doc,
set_item
),
@@ -71,8 +79,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack",
has_selection,
"{index | indices: [..] (paint-order item indices), ids?, target?} remove those fills/strokes from each target object's own stack",
has_doc,
remove_item
),
cmd!(
@@ -80,8 +88,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Effect",
[],
None,
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?} → {ids, index, item}",
has_selection,
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
has_doc,
super::effectcmd::apply
),
cmd!(
@@ -89,11 +97,19 @@ pub fn specs() -> Vec<CommandSpec> {
"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,
"{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?, target?} copy fills/strokes with their effects",
has_doc,
duplicate_item
),
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item),
cmd!(
"appearance.moveItem",
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", to, contents?: count, ids?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
cmd!(
"appearance.copyFrom",
"Eyedropper",
@@ -117,10 +133,19 @@ pub fn specs() -> Vec<CommandSpec> {
"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,
"{ids?, target?} 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_doc,
show_all_hidden
),
cmd!(
"appearance.targetContents",
"Target Contents",
[],
None,
"{ids?} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
has_doc,
target_contents
),
]
}
@@ -182,7 +207,8 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
.as_u64()
.map(|i| ItemTarget::Item { index: i as usize, explicit: true })
.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),
// The active item is a row of the first selected object's own stack.
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })),
}
}
@@ -234,14 +260,38 @@ impl ItemTarget {
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id`, else the selected objects
/// (a selected compound-path member stands for its compound). Layers are skipped.
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if p.get("ids").is_none() && p.get("id").is_none() {
return selected_roots(s);
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
fn targets_contents(p: &Value) -> Result<bool> {
match p.get("target") {
None | Some(Value::Null) => Ok(false),
Some(v) => match v.as_str() {
Some("object") => Ok(false),
Some("contents") => Ok(true),
_ => Err(bad("appearance", format!("`target` must be \"object\" or \"contents\", not {v}"))),
},
}
let d = &s.doc()?.doc;
Ok(targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect())
}
/// 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`]).
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)?
} else {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
};
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
/// [`appearance_targets`] of `cmd`, which needs at least one.
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
Ok(ids)
}
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
@@ -305,14 +355,28 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
}
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
let defaults = s.paint.clone();
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
// A copy of the top fill (stroke); without one, type's characters' and otherwise the
// default paint, so a new fill on a group or type shows.
let paint = match (n.appearance.paint_at(None, fill), &n.kind) {
(Some(p), _) => p.clone(),
(None, NodeKind::Text(_)) => super::paint::proxy_paint(n, !fill, None),
(None, _) => {
if fill {
defaults.fill.clone()
} else {
defaults.stroke.clone()
}
}
};
let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint()))
AppearanceItem::Fill(FillLayer::new(paint))
} else {
AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0)))
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
}
@@ -323,7 +387,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
}
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, "appearance.clear")?;
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -340,12 +404,13 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// 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 { .. })
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -356,7 +421,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
// keeps the rows it had.
let fill =
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone()))));
n.appearance = Appearance { items: fill.into_iter().collect(), effects: vec![] };
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() };
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear();
st.opacity = 1.0;
@@ -433,16 +498,13 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?;
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, "appearance.removeItem")?;
s.edit("Remove Item", |d, _| {
for id in &ids {
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);
}
n.appearance.remove_item(i);
}
}
}
@@ -459,20 +521,20 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
if to.is_some() && idx.len() > 1 {
return Err(bad(C, "`to` takes a single `index`"));
}
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, C)?;
// 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 };
let items = &mut n.appearance.items;
let ap = &mut n.appearance;
// 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()));
if let Some(item) = ap.items.get(i).cloned() {
let at = to.map_or(i + 1, |t| t.min(ap.items.len()));
first_at.get_or_insert(at);
items.insert(at, item);
ap.insert_item(at, item);
any = true;
}
}
@@ -490,7 +552,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
}
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let roots = require_targets(s, p, "appearance.showAllHidden")?;
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() {
@@ -503,20 +565,21 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let from = index_param(p, "from", C)?;
let to = index_param(p, "to", C)?;
let ids = appearance_targets(s, p)?;
let ids = require_targets(s, p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
return move_contents(s, &ids, to);
}
let from = index_param(p, "from", C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
let mut landed = to;
s.edit("Reorder Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let items = &mut n.appearance.items;
if from >= items.len() {
return Err(bad(C, format!("no item at index {from}")));
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
n.appearance.set_contents_at(k);
}
let it = items.remove(from);
landed = to.min(items.len());
items.insert(landed, it);
}
Ok(())
})?;
@@ -532,7 +595,38 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
a
})
});
ok()
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
/// has one above its bottom `to` items.
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
let slot = s.edit("Reorder Appearance", |d, _| {
let mut slot = None;
for id in ids {
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
n.appearance.set_contents_at(to);
slot.get_or_insert(n.appearance.contents_at());
}
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))
})?;
Ok(json!({ "contents": slot }))
}
fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = roots
.iter()
.filter_map(|id| d.node(*id))
.filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
.flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id))
.collect();
if ids.is_empty() {
return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members"));
}
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -198,11 +198,11 @@ fn finite(p: Point) -> bool {
}
fn fill_only(paint: Paint) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], effects: vec![] }
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() }
}
fn stroke_only(paint: Paint, width: f64) -> Appearance {
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], effects: vec![] }
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
}
/// A point strictly inside a filled path (for re-finding faces after a rebuild).
+24 -31
View File
@@ -1,7 +1,9 @@
//! Effect menu commands (live effects) and Object → Expand Appearance.
//!
//! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render
//! time by `vectorcraft-effects` (re-exported by the renderer).
//! time by `vectorcraft-effects` (re-exported by the renderer). The commands edit the objects'
//! own stacks, groups and layers included (layers through `ids`), or with `target: "contents"`
//! the objects inside them (see `appearance`).
use std::sync::Arc;
@@ -20,8 +22,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..]} append a live effect to each selected object's appearance → {ids, index, item}",
has_selection,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
has_doc,
apply
),
cmd!(
@@ -39,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}",
has_selection,
has_doc,
remove
),
cmd!(
@@ -48,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}",
has_selection,
has_doc,
set_params
),
cmd!(
@@ -56,8 +58,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}",
has_selection,
"{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,
expand_appearance
),
cmd!(
@@ -66,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}",
has_selection,
has_doc,
duplicate
),
cmd!(
@@ -75,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
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,
has_doc,
move_effect
),
]
@@ -256,31 +258,22 @@ fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
}
}
/// Bake the object-level geometry effects of `id` (and, recursively, of group members).
/// 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 };
// Effect → Pathfinder: the group's members become the Pathfinder result.
if let Some(result) = effects::pathfinder_children(&n, None) {
let children = result
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
Arc::new(c)
})
.collect();
let Some(m) = d.node_mut(id) else { return };
if let Some(ch) = m.children_mut() {
*ch = children;
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);
}
m.appearance.effects.retain(|e| !effects::is_pathfinder(&e.id));
out.push(id);
return;
}
if let Some(children) = n.children()
&& matches!(n.kind, NodeKind::Group { .. })
{
for c in children.iter().map(|c| c.id).collect::<Vec<_>>() {
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);
}
return;
+3 -3
View File
@@ -363,7 +363,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
// Outline: open edges stroked with the face's former fill.
let fill = st.appearance.fill_paint();
let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill };
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] };
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() };
}
children.push(Arc::new(n));
}
@@ -483,7 +483,7 @@ fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path:
overprint: st.overprint,
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
};
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] };
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() };
Some(n)
}
@@ -499,7 +499,7 @@ fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &N
let mut fills: Vec<AppearanceItem> = vec![];
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
let mut f = d.reid(l);
f.appearance = Appearance { items, effects: vec![] };
f.appearance = Appearance { items, ..Default::default() };
f.opacity = 1.0;
f.blend = BlendMode::Normal;
f.name = None;
+16 -21
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
"{name, add?: bool, ids? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
has_doc,
style_apply
),
@@ -126,22 +126,19 @@ fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
}
}
/// [`painted`], mutably (copying only the nodes on the way).
fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) {
if !descends(n, top) {
return f(n);
}
for c in n.children_mut().into_iter().flatten() {
paint_mut(std::sync::Arc::make_mut(c), false, f);
}
}
/// Does `n` still have style `g`'s look (its transparency, and its appearance on every painted
/// object)? Linked objects that don't are no longer linked.
/// Does `n` still have style `g`'s look (its transparency, and the style's appearance as its own;
/// a group or layer with no appearance of its own: on every painted object inside it, the look a
/// style made from it captures)? Linked objects that don't are no longer linked.
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
if !g.transparency_matches(n) {
return false;
}
if g.appearance_on(n).approx_eq(&n.appearance) {
return true;
}
let mut leaves = vec![];
painted(n, true, &mut leaves);
g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance))
n.appearance == Appearance::default() && leaves.iter().all(|l| l.id != n.id && g.appearance_on(l).approx_eq(&l.appearance))
}
/// The style `n` is linked to (and still looks like).
@@ -214,10 +211,10 @@ fn captured(n: &Node) -> Appearance {
ap
}
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
/// Give `id` style `g` (a group or layer too: its own appearance, above its contents) and link it.
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
if let Some(n) = d.node_mut(id) {
paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned());
n.appearance = g.appearance_on(n).into_owned();
g.apply_transparency(n);
n.graphic_style = Some(g.id);
}
@@ -270,11 +267,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let g = d.graphic_styles[i].clone();
for id in &ids {
if let Some(n) = d.node_mut(*id) {
paint_mut(n, true, &mut |l| {
let ap = g.appearance_on(l).into_owned();
l.appearance.items.extend(ap.items);
l.appearance.effects.extend(ap.effects);
});
let ap = g.appearance_on(n).into_owned();
n.appearance.items.extend(ap.items);
n.appearance.effects.extend(ap.effects);
n.graphic_style = None;
}
}
+2
View File
@@ -752,6 +752,8 @@ mod tests_colorguide;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_containers;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_distort;
+147
View File
@@ -0,0 +1,147 @@
//! Group, layer and type appearance through commands: their own fills, strokes and effects, the
//! Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
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()
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A selected group of two 40×40 squares at (20, 20) and (100, 20) → (group, [members]).
fn group(s: &mut Session) -> (NodeId, [NodeId; 2]) {
let a = id_of(&run(s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})));
let b = id_of(&run(s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40})));
run(s, "select.set", json!({"ids": [a.0, b.0]}));
run(s, "paint.setFill", json!({"color": "#ff0000"}));
(id_of(&run(s, "object.group", json!({}))), [a, b])
}
fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] {
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).pixel(x, y)
}
const GREEN: [u8; 4] = [0, 255, 0, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
#[test]
fn a_group_fill_paints_both_members_and_the_contents_slot_moves() {
let mut s = session();
let (g, [a, _]) = group(&mut s);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "item": 0}));
let n = node(&s, g);
assert_eq!((n.appearance.items.len(), n.appearance.contents_at()), (1, 0));
// The members keep their own fill; the group's paints over both of them.
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!((pixel(&s, 40, 40), pixel(&s, 120, 40), pixel(&s, 80, 40)), (GREEN, GREEN, [255; 4]));
// Contents above the fill: the members show again; one undo step brings it back.
assert_eq!(run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}))["contents"], 1);
assert_eq!((node(&s, g).appearance.contents_index, pixel(&s, 40, 40)), (Some(1), RED));
run(&mut s, "edit.undo", json!({}));
assert_eq!(pixel(&s, 40, 40), GREEN);
// A stroke dragged under the contents in one step with `contents`.
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 0, "contents": 1}));
let ap = node(&s, g).appearance;
assert!(!ap.items[0].is_fill() && ap.contents_at() == 1, "{ap:?}");
// Paths have no Contents row.
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert!(s.execute("appearance.moveItem", &json!({"from": "contents", "to": 1})).is_err());
}
#[test]
fn appearance_commands_target_the_object_or_its_contents() {
let mut s = session();
let (g, [a, b]) = group(&mut s);
let before = node(&s, a).appearance.items.len();
run(&mut s, "appearance.addStroke", json!({"target": "contents"}));
assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (before + 1, before + 1));
assert!(node(&s, g).appearance.items.is_empty());
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "target": "contents"}));
assert_eq!((node(&s, a).appearance.effects.len(), node(&s, g).appearance.effects.len()), (1, 0));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
assert_eq!(node(&s, g).appearance.effects.len(), 1);
assert!(s.execute("appearance.addFill", &json!({"target": "inside"})).is_err());
// Target Contents selects the members (what double-clicking the Contents row does).
assert_eq!(run(&mut s, "appearance.targetContents", json!({}))["ids"], json!([a.0, b.0]));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
assert!(s.execute("appearance.targetContents", &json!({})).is_err(), "paths have no members");
}
#[test]
fn effects_and_fills_apply_to_layers_by_id() {
let mut s = session();
let (g, _) = group(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "select.none", json!({}));
let r = run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [layer.0]}));
assert_eq!(r["ids"], json!([layer.0]));
run(&mut s, "appearance.addFill", json!({"ids": [layer.0]}));
let l = node(&s, layer);
assert_eq!((l.appearance.effects.len(), l.appearance.items.len()), (1, 1));
assert!(node(&s, g).appearance.effects.is_empty());
// Two undo steps take both off again.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, layer).appearance, Default::default());
// Nothing selected and no ids: an error, not a silent no-op.
assert!(s.execute("appearance.addFill", &json!({})).is_err());
}
#[test]
fn type_fills_go_above_or_below_the_characters() {
let mut s = session();
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "select.set", json!({"ids": [t.0]}));
// A new fill on type copies its characters' fill (it has none of its own).
run(&mut s, "appearance.addFill", json!({}));
let n = node(&s, t);
let NodeKind::Text(tx) = &n.kind else { unreachable!() };
assert_eq!(n.appearance.fill_paint(), tx.runs[0].style.fill);
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
assert_eq!(node(&s, t).appearance.contents_at(), 1);
}
#[test]
fn group_appearance_round_trips_and_expands() {
let mut s = session();
let (g, _) = group(&mut s);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}}));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(g).unwrap().appearance, doc.node(g).unwrap().appearance);
assert_eq!(back.node(g).unwrap().appearance.contents_index, Some(1));
// Expand Appearance: the fill and stroke become paths below and above the moved members.
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, g);
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).
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);
}
+8 -2
View File
@@ -99,12 +99,18 @@ fn a_style_from_a_group_or_type_is_not_empty() {
run(&mut s, "graphicStyle.new", json!({"name": "Type Look"}));
assert!(!style(&s, "Type Look").appearance.fill_paint().is_none());
// Applying to a group styles its contents and links the group.
// Applying to a group gives the group itself the style (its fills paint the members, which
// keep their own look) and links it; `target: "contents"` styles the members instead.
let c = rect(&mut s, 200.0);
let before = node(&s, a).appearance;
run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]}));
assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance);
assert_eq!(node(&s, g).appearance, style(&s, "Black Outline").appearance);
assert_eq!(node(&s, a).appearance, before);
assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id));
assert_eq!(linked(&mut s, "Black Outline"), vec![g.0]);
assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance);
run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0], "target": "contents"}));
assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance);
}
#[test]