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:33:01 +07:00
parent 25c8f32fdd
commit 8a837fd4b9
35 changed files with 1282 additions and 245 deletions

View File

@ -11,7 +11,7 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes}
use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter};
fn fill(c: Color) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], effects: vec![] }
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], ..Default::default() }
}
fn path(p: PathData, c: Color) -> Node {

View File

@ -432,7 +432,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
/// A filled path node painted with `paint` (brush output).
pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node {
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], effects: vec![] })
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], ..Default::default() })
}
/// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).

View File

@ -375,12 +375,17 @@ pub struct Appearance {
pub items: Vec<AppearanceItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
/// Groups, layers and type: how many items paint below the object's contents (its members, or
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
/// every item paints above the contents (see [`Self::contents_at`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contents_index: Option<usize>,
}
impl Appearance {
/// Illustrator's "basic appearance": one fill below one stroke.
pub fn basic(fill: Paint, stroke: Paint, width: f64) -> Self {
Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], effects: vec![] }
Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], ..Self::default() }
}
/// White fill, 1 pt black stroke (the default for new art).
pub fn default_art() -> Self {
@ -410,7 +415,7 @@ impl Appearance {
pub fn set_fill(&mut self, p: Paint) {
match self.fill_mut() {
Some(f) => f.paint = p,
None => self.items.insert(0, AppearanceItem::Fill(FillLayer::new(p))),
None => self.insert_item(0, AppearanceItem::Fill(FillLayer::new(p))),
}
}
/// Set the top stroke's paint, creating a 1 pt stroke if there is none.
@ -428,6 +433,7 @@ impl Appearance {
pub fn is_basic(&self) -> bool {
let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count();
self.effects.is_empty()
&& self.contents_index.is_none()
&& count(true) <= 1
&& count(false) <= 1
&& self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal)
@ -679,6 +685,59 @@ impl Appearance {
}
}
/// The contents slot: where a group's or layer's members (type: its characters) paint among the
/// object's own fills and strokes.
impl Appearance {
/// How many items paint below the contents (the Contents/Characters row sits above them).
pub fn contents_at(&self) -> usize {
self.contents_index.unwrap_or(0).min(self.items.len())
}
/// Put the contents above the bottom `k` items (clamped; 0, all items above, is stored as `None`).
pub fn set_contents_at(&mut self, k: usize) {
let k = k.min(self.items.len());
self.contents_index = (k > 0).then_some(k);
}
/// The items painted below the contents and those painted above them.
pub fn split_contents(&self) -> (&[AppearanceItem], &[AppearanceItem]) {
self.items.split_at(self.contents_at())
}
/// Insert `item` at paint-order index `at` (clamped). The contents stay between the same items;
/// an item landing right above them joins the item below it (below the contents if that one is).
pub fn insert_item(&mut self, at: usize, item: AppearanceItem) {
let k = self.contents_at();
let at = at.min(self.items.len());
self.items.insert(at, item);
if at < k || (at == k && k > 0) {
self.set_contents_at(k + 1);
}
}
/// Remove item `i` (if it exists), keeping the contents between the same items.
pub fn remove_item(&mut self, i: usize) -> Option<AppearanceItem> {
let k = self.contents_at();
(i < self.items.len()).then(|| {
let it = self.items.remove(i);
if i < k {
self.set_contents_at(k - 1);
}
it
})
}
/// Move item `from` to paint-order index `to` (clamped) and return where it landed, or `None`
/// when there is no item `from`. The contents stay between the same other items, and the moved
/// item keeps its side of them when it lands right next to them.
pub fn move_item(&mut self, from: usize, to: usize) -> Option<usize> {
let k = self.contents_at();
let below = from < k;
let it = self.remove_item(from)?;
let others_below = self.contents_at();
let to = to.min(self.items.len());
self.items.insert(to, it);
let now_below = if below { to <= others_below } else { to < others_below };
self.set_contents_at(others_below + usize::from(now_below));
Some(to)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -744,7 +803,7 @@ mod tests {
#[test]
fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
let mut a = Appearance { items: vec![stroke(), stroke()], effects: vec![] };
let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() };
assert!(!a.is_basic(), "two strokes");
a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic());
@ -831,4 +890,40 @@ mod tests {
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 0.0);
}
#[test]
fn contents_slot_follows_item_edits_and_round_trips() {
let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None));
let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() };
assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default");
a.set_contents_at(1);
assert!(!a.is_basic());
// [0 | 1 2]: removing below shifts the slot, removing above doesn't.
a.remove_item(2);
assert_eq!(a.contents_at(), 1);
a.remove_item(0);
assert_eq!((a.contents_at(), a.contents_index), (0, None));
// Inserting next to the slot joins the item below it.
a.items.push(fill());
a.set_contents_at(1); // [0 | 1]
a.insert_item(1, fill());
assert_eq!(a.contents_at(), 2);
a.insert_item(3, fill()); // [0 1 | 2 3]
assert_eq!(a.contents_at(), 2);
// Moves keep their side when landing next to the slot, cross it otherwise.
assert_eq!(a.move_item(3, 2), Some(2));
assert_eq!(a.contents_at(), 2, "stays above");
a.move_item(3, 0);
assert_eq!(a.contents_at(), 3, "crossed below");
a.move_item(0, 3);
assert_eq!(a.contents_at(), 2, "crossed above");
assert_eq!(a.move_item(9, 0), None);
// Saved only when set; older files load with every item above.
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contents_index\":2"));
assert_eq!(serde_json::from_str::<Appearance>(&json).unwrap(), a);
let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap();
assert_eq!(old.contents_index, None);
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
}
}

View File

@ -456,7 +456,9 @@ impl Node {
children.first().and_then(|c| c.geometric_bounds())
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
@ -793,6 +795,19 @@ impl Node {
}
}
impl Node {
/// The Appearance panel's row for what this object's own fills and strokes paint around:
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
/// whose fills and strokes paint their own geometry.
pub fn contents_label(&self) -> Option<&'static str> {
match self.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
NodeKind::Text(_) => Some("Characters"),
_ => None,
}
}
}
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();

View File

@ -109,7 +109,7 @@ impl CharStyle {
pub fn appearance(&self) -> crate::Appearance {
let mut fill = crate::FillLayer::new(self.fill.clone());
fill.overprint = self.overprint_fill;
let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], effects: vec![] };
let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], ..Default::default() };
if !self.stroke.is_none() && self.stroke_width > 0.0 {
let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width);
st.overprint = self.overprint_stroke;

View File

@ -3,17 +3,17 @@
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a
//! copy of the document in which every geometry effect — object level, per fill/stroke and
//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//! 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).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData};
use crate::{
GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape,
};
use crate::group::has_own_paint;
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] {
match item {
@ -32,6 +32,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
@ -79,14 +80,16 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
if let Some(result) = pathfinder_children(n, None) {
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
let children = result
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
if let Some(mut m) = evaluate_container(n) {
let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
@ -111,6 +114,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n)
}
/// Give `n` and its descendants a new id wherever one was already `seen` (the pieces evaluated art
/// copies from its source keep ids of their own).
pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::HashSet<vectorcraft_doc::NodeId>) {
if !seen.insert(n.id) {
n.id = d.alloc_id();
seen.insert(n.id);
}
for c in n.children_mut().into_iter().flatten() {
fresh_ids(d, Arc::make_mut(c), seen);
}
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
@ -136,7 +151,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], effects: vec![] },
appearance: Appearance { items: vec![it], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,

View File

@ -1,14 +1,20 @@
//! Effect → Pathfinder: live Pathfinder operations on a group (or layer).
//! The appearance of groups and layers.
//!
//! The group's direct children are the Pathfinder stack (back → front). In the shape modes (Add,
//! Subtract, Intersect, Exclude, Minus Back) a child group acts as one shape — the union of its
//! leaves, styled like its top leaf — as in the Pathfinder panel. The result replaces the children
//! at render time; each result keeps the appearance of the object whose paint it takes.
//! **Effect → Pathfinder**: live Pathfinder operations on a group (or layer). The group's direct
//! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect,
//! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its
//! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each
//! result keeps the appearance of the object whose paint it takes.
//!
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
//! paint every member's geometry, below or above the members as its contents slot says
//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster
//! effects (one combined shadow) apply to the composite and stay for the renderer and exporters.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
@ -74,11 +80,11 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// A result path styled like `src` (its appearance, opacity and blend mode).
fn styled(src: &Node, path: PathData, outline: bool) -> Node {
let mut appearance = Appearance { items: src.appearance.items.clone(), effects: vec![] };
let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() };
if outline {
let fill = src.appearance.fill_paint();
let paint = if fill.is_none() { src.appearance.stroke_paint() } else { fill };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() };
}
let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity;
@ -130,6 +136,145 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
Some(stack)
}
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Does `item` paint anything?
fn paints(item: &AppearanceItem) -> bool {
item.visible()
&& !item.paint().is_none()
&& match item {
AppearanceItem::Stroke(s) => s.width > 0.0,
AppearanceItem::Fill(_) => true,
}
}
/// Do the own fills or strokes of `n`, a group or layer, paint anything?
pub(crate) fn has_own_paint(n: &Node) -> bool {
is_container(n) && n.appearance.items.iter().any(paints)
}
/// Does `n`, a group or layer, have an appearance of its own to evaluate: a fill or stroke that
/// paints, or a visible effect other than Pathfinder (evaluated by [`pathfinder_children`])?
pub fn has_container_appearance(n: &Node) -> bool {
is_container(n) && (has_own_paint(n) || n.appearance.effects.iter().any(|e| e.visible && !is_pathfinder(&e.id)))
}
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
/// Images, symbol instances and guides add nothing.
pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> {
fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Text(t) => {
let (_, all) = crate::reshape::glyph_outlines(t);
if !all.elements().is_empty() {
out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero));
}
}
NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out),
_ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out),
_ => {}
}
}
fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
push(c, out);
}
}
let mut out = vec![];
members(n, &mut out);
out
}
/// A path node of `id` on `path` (filled under `rule`) painted by `ap`.
fn shape_node(id: NodeId, path: PathData, rule: FillRule, ap: Appearance) -> Node {
let mut n = Node::path(id, path, ap);
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = rule;
}
n
}
/// One of a container's own fills or strokes as art: `item` painting every shape of `shapes`, an
/// unplaced gradient placed across `bounds` (the whole group). Several shapes share the item's
/// opacity and blend mode as one layer, as one fill would. `None` when it paints nothing.
fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], bounds: Option<Rect>) -> Option<Node> {
if !paints(item) || shapes.is_empty() {
return None;
}
let mut ap = Appearance { items: vec![item.clone()], ..Default::default() };
if let Some(b) = bounds {
ap.pin_gradients(b);
}
if let [(path, rule)] = shapes {
return Some(shape_node(id, path.clone(), *rule, ap));
}
let (opacity, blend) = (item.opacity(), item.blend());
match &mut ap.items[0] {
AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, Default::default()),
AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, Default::default()),
}
let children = shapes.iter().map(|(p, r)| Arc::new(shape_node(id, p.clone(), *r, ap.clone()))).collect();
let mut g = Node::group(id, children);
(g.opacity, g.blend) = (opacity, blend);
Some(g)
}
/// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects
/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
/// the members (after a clipping path) or above them, as its contents slot says. Its
/// transparency, opacity mask and raster effects stay. `None` when it has none of these.
pub fn evaluate_container(n: &Node) -> Option<Node> {
if !is_container(n) {
return None;
}
let pathfinder = has_pathfinder(n);
let fx = crate::reshape::geometry_effects(n);
let paint = has_own_paint(n);
if !pathfinder && fx.is_empty() && !paint {
return None;
}
let mut m = n.clone();
if pathfinder {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
let result = pathfinder_children(n, Some(hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(&m);
crate::reshape::reshape_leaves(&mut m, &fx);
}
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
if paint {
let shapes = member_shapes(&m);
let bounds = m.geometric_bounds();
let art =
|items: &[AppearanceItem]| -> Vec<Arc<Node>> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() };
let (below, above) = m.appearance.split_contents();
let (below, above) = (art(below), art(above));
let at = usize::from(m.clips());
if let Some(ch) = m.children_mut() {
let at = at.min(ch.len());
ch.splice(at..at, below);
ch.extend(above);
}
}
m.appearance.items.clear();
m.appearance.contents_index = None;
Some(m)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -14,6 +14,8 @@
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//!
@ -39,9 +41,11 @@ 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, needs_bake};
pub use bake::{bake_document, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
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 warp::{WarpStyle, warp_point};

View File

@ -9,7 +9,7 @@
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
@ -29,11 +29,8 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// then (when the object has its own fills/strokes) the whole outline with the object's stack,
/// painted over the characters as the renderer does.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
@ -43,13 +40,22 @@ pub fn outline_text(n: &Node) -> Option<Node> {
}
all.extend(g.outline.iter());
}
(runs, all)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
/// the characters under them and the others over them, as the renderer paints them.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let (runs, all) = glyph_outlines(t);
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| {
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] };
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() };
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 {
ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width)));
}
@ -57,8 +63,14 @@ pub fn outline_text(n: &Node) -> Option<Node> {
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] };
children.push(Arc::new(Node::path(n.id, path(&all), ap)));
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
if !below.is_empty() {
children.insert(0, glyphs(below));
}
if !above.is_empty() {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
}
@ -107,7 +119,7 @@ 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: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
let fx = geometry_effects(n);
if fx.is_empty() || !needs_outline(n) {
return None;
}
@ -123,16 +135,7 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
}
_ => outline_art(n, symbol_art)?,
};
for e in fx {
// Every leaf takes the effect with the whole art's bounds as its reference box.
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(&mut art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
reshape_leaves(&mut art, &fx);
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
@ -146,6 +149,34 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(out)
}
/// The visible geometry effects of `n`'s own effect list, in order.
pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect()
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box.
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
}
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
outline_nested_text(&mut art);
art
}
/// Run `f` on every path and compound path under `n` (guides skipped).
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
match &mut n.kind {
@ -159,7 +190,7 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
}
}
fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {

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> {

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

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;

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;

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

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;

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

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]

View File

@ -678,6 +678,18 @@ impl Exporter<'_> {
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds);
// The object's own fills and strokes paint the whole outline: those below the Characters
// row under the characters, the others over them.
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
all.apply_affine(t.xf);
to_path(&all)
});
let all = all.flatten();
if let Some(path) = &all {
self.text_items(s, below, path, tb);
}
s.push_transform(&xf(t.xf));
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
@ -701,34 +713,36 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
s.pop();
if !n.appearance.items.is_empty() {
let mut all = layout.to_bezpath();
all.apply_affine(t.xf);
let Some(path) = to_path(&all) else { return };
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if let Some(paint) = self.paint(&fl.paint, tb) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) {
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
s.draw_path(&path);
}
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
if let Some(path) = &all {
self.text_items(s, above, path, tb);
}
}
/// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds).
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) {
for item in items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if let Some(paint) = self.paint(&fl.paint, tb) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(path);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) {
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
s.draw_path(path);
}
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
}
fn load_image(&mut self, key: &str) -> Option<Image> {
if let Some(i) = self.images.get(key) {
return i.clone();

View File

@ -461,7 +461,7 @@ impl<'a> Device<'a> for Builder {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
f.opacity = opacity;
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] });
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule);
}
@ -480,7 +480,7 @@ impl<'a> Device<'a> for Builder {
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
return;
}
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
self.push_node(n);
}
}

View File

@ -37,7 +37,7 @@ fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() {
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], ..Default::default() },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();

View File

@ -13,7 +13,7 @@ pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
let mut n = Node::path(
NodeId(0),
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();

View File

@ -56,7 +56,7 @@ fn outside_strokes_keep_their_miter_spikes() {
s.join = LineJoin::Miter;
s.miter_limit = 10.0;
});
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();

View File

@ -15,7 +15,10 @@
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
//! drawn offscreen in the filter layers, reshaped first by their geometry effects.
//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and
//! layers with an appearance of their own take the same path with their evaluated art
//! ([`effects::evaluate_container`]: their fills and strokes painting the members, their geometry
//! effects reshaping them), so their raster effects apply to the composite (one combined shadow).
use std::sync::Arc;
@ -45,9 +48,15 @@ pub(crate) fn has_fx(n: &Node) -> bool {
}
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; see [`Renderer::draw_object_fx`])?
/// symbol instances, live objects), or is it a group or layer with an appearance of its own (see
/// [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
effects::needs_outline(n) && visible(&n.appearance.effects)
(effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n)
}
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// What [`Renderer::raster_fx`] paints effects around.
@ -127,6 +136,18 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
/// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
match &n.kind {
// The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach.
_ if effects::has_container_appearance(n) => {
let art = effects::evaluate_container(n);
let art = art.as_ref().unwrap_or(n);
let b = if art.clips() {
art.visual_bounds()
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
}?;
let o = effects::outset(&n.appearance.effects);
Some(b.inflate(o, o))
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
}
@ -375,7 +396,8 @@ impl Renderer {
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):
/// reshaped by its geometry effects, else type and images as themselves and the others as
/// their evaluated art; without the object's transparency and effects.
/// their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their
/// knockout setting); without the object's transparency and effects.
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize;
if cache
@ -390,8 +412,10 @@ impl Renderer {
}
_ => None,
};
let container = is_container(a);
let art = match effects::reshape(a, symbol.as_ref()) {
Some(r) => r,
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(),
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
};
@ -399,8 +423,9 @@ impl Renderer {
opacity: 1.0,
blend: Default::default(),
isolate: false,
// One element: its pieces never knock each other out.
knockout: vectorcraft_doc::Knockout::Off,
// One element: its pieces never knock each other out (a group's members still do as
// its knockout setting says).
knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off },
mask: None,
appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance },
..art

View File

@ -456,7 +456,7 @@ impl Renderer {
return e.bounds;
}
let b = match &a.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => {
let mut acc: Option<Rect> = None;
for c in children {
if c.visible {
@ -920,7 +920,17 @@ impl Renderer {
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
}
};
// Object-level appearance fills/strokes apply on top of character fills (like Illustrator).
// The object's own fills and strokes paint the whole outline: those below the Characters
// row under the characters' own fill and stroke, the others over them.
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = g.all.clone();
all.apply_affine(t.xf);
all
});
if let Some(all) = &all {
self.draw_text_items(ctx, f, below, all, tb);
}
for (i, run) in t.runs.iter().enumerate() {
let Some(path) = g.runs.get(i) else { continue };
if path.elements().is_empty() {
@ -943,31 +953,34 @@ impl Renderer {
}
}
overprint(ctx, false);
if !n.appearance.items.is_empty() {
let mut all = g.all.clone();
all.apply_affine(t.xf);
for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
// Each item composites with its own opacity and blend mode.
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
if let Some(all) = &all {
self.draw_text_items(ctx, f, above, all, tb);
}
}
/// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document
/// space; `tb` their bounds), each with its own opacity and blend mode.
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
}
ctx.set_transform(f.view);
match item {
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(all);
}
ctx.set_transform(f.view);
match item {
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&all);
}
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
ctx.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(&all);
}
_ => {}
}
if layered {
ctx.pop_layer();
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
ctx.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(all);
}
_ => {}
}
if layered {
ctx.pop_layer();
}
}
}
@ -1141,6 +1154,8 @@ mod tests_blend;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_isolation;
#[cfg(test)]
mod tests_knockout;

View File

@ -0,0 +1,188 @@
//! The appearance of groups, layers and type: a container's own fills and strokes paint its
//! members' geometry below or above them (the contents slot), its effects apply to the composite
//! (one combined shadow) and reshape its members as one piece; type's own fills paint under or
//! over its characters.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{Point, shapes};
use super::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn solid(r: f32, g: f32, b: f32) -> Paint {
Paint::solid(Color::rgb(r, g, b))
}
/// A rectangle filled with `paint` and no stroke.
fn rect(d: &mut Document, r: Rect, paint: Paint) -> Arc<Node> {
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0)))
}
/// A 100×100 document holding a group of a red square (10..40) and a blue one (50..80), on a row
/// from y 10 to 40, given its own appearance by `style`.
fn group_doc(style: impl FnOnce(&mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), solid(1.0, 0.0, 0.0));
let b = rect(&mut d, Rect::new(50.0, 10.0, 80.0, 40.0), solid(0.0, 0.0, 1.0));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
style(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
}
fn render_threads(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn render(d: &Document) -> Rendered {
render_threads(d, 0)
}
const GREEN: [u8; 4] = [0, 255, 0, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
const WHITE: [u8; 4] = [255, 255, 255, 255];
fn green_fill(g: &mut Node) {
g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
}
#[test]
fn a_group_fill_paints_every_member_above_or_below_them() {
// Above the contents (the default): both members turn green; the gap between them stays white.
let img = render(&group_doc(green_fill));
assert_eq!((img.pixel(25, 25), img.pixel(65, 25), img.pixel(45, 25)), (GREEN, GREEN, WHITE));
// Below the contents the members' own fills cover it.
let img = render(&group_doc(|g| {
green_fill(g);
g.appearance.set_contents_at(1);
}));
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (RED, BLUE));
// A group stroke strokes each member's outline.
let img = render(&group_doc(|g| {
g.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(solid(0.0, 1.0, 0.0), 4.0)));
}));
assert_eq!((img.pixel(10, 25), img.pixel(80, 25), img.pixel(25, 25)), (GREEN, GREEN, RED));
// Hidden fills paint nothing.
let img = render(&group_doc(|g| {
green_fill(g);
if let AppearanceItem::Fill(f) = &mut g.appearance.items[0] {
f.visible = false;
}
}));
assert_eq!(img.pixel(25, 25), RED);
}
#[test]
fn a_group_fill_with_opacity_composites_as_one_layer() {
// Two overlapping members under a 50 % group fill: the overlap is as light as the rest.
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 60.0, 40.0), Paint::None);
let b = rect(&mut d, Rect::new(40.0, 10.0, 90.0, 40.0), Paint::None);
let mut g = Node::group(d.alloc_id(), vec![a, b]);
let mut f = FillLayer::new(Paint::solid(Color::BLACK));
f.opacity = 0.5;
g.appearance.items.push(AppearanceItem::Fill(f));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let img = render(&d);
let (single, both) = (img.pixel(20, 25), img.pixel(50, 25));
assert!(single[0].abs_diff(128) <= 2 && single == both, "{single:?} vs {both:?}");
}
#[test]
fn a_group_shadow_is_one_combined_shadow() {
// The members' shadows (offset 30 pt right) overlap at x 65 but not at x 80; one combined
// shadow is equally dark at both, two separate ones would double up at 65.
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Paint::solid(Color::BLACK));
let b = rect(&mut d, Rect::new(30.0, 10.0, 60.0, 40.0), Paint::solid(Color::BLACK));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 50, "mode": "normal"})));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
for threads in [0, 3] {
let img = render_threads(&d, threads);
let (overlap, single) = (img.pixel(65, 25), img.pixel(80, 25));
assert!(single[0] < 160 && single[0] > 100, "{single:?}");
assert!(overlap[0].abs_diff(single[0]) <= 2, "one shadow, not two: {overlap:?} vs {single:?}");
assert_eq!(img.pixel(95, 25), WHITE);
}
}
#[test]
fn group_geometry_effects_move_the_members_as_one_piece() {
let img = render(&group_doc(|g| g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})))));
assert_eq!((img.pixel(25, 25), img.pixel(25, 75), img.pixel(65, 75)), (WHITE, RED, BLUE));
// The group's own fill paints the moved members, and the shadow follows them.
let img = render(&group_doc(|g| {
g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})));
green_fill(g);
}));
assert_eq!((img.pixel(25, 75), img.pixel(65, 75)), (GREEN, GREEN));
}
#[test]
fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() {
let mut d = group_doc(|_| {});
let l = Arc::make_mut(&mut d.layers[0]);
l.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
let img = render(&d);
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (GREEN, GREEN));
// A knockout group with a drop shadow: its half-transparent members still knock each other
// out (the overlap shows the top one over the backdrop), and it casts its shadow.
let mut d = Document::new(100.0, 100.0);
let half = |d: &mut Document, r: Rect, p: Paint| {
let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0));
n.opacity = 0.5;
Arc::new(n)
};
let a = half(&mut d, Rect::new(10.0, 10.0, 50.0, 40.0), solid(1.0, 0.0, 0.0));
let b = half(&mut d, Rect::new(30.0, 10.0, 70.0, 40.0), solid(0.0, 0.0, 1.0));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
g.knockout = Knockout::On;
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 0, "y": 40, "blur": 0, "opacity": 100, "mode": "normal"})));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let img = render(&d);
let overlap = img.pixel(40, 25);
assert!(overlap[0] > 120 && overlap[2] > 250, "the blue knocks the red out: {overlap:?}");
// The shadow of the composite: as opaque as the knocked-out art (half), the overlap too.
assert!(img.pixel(40, 65)[0].abs_diff(127) <= 2 && img.pixel(20, 65) == img.pixel(40, 65), "the shadow: {:?}", img.pixel(40, 65));
}
/// "IIII" in black at 40 pt with its own red fill; `below`: under the characters.
fn type_doc(below: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(1.0, 0.0, 0.0))));
if below {
n.appearance.set_contents_at(1);
}
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn red_pixels(img: &Rendered) -> usize {
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
}
#[test]
fn a_fill_below_the_characters_is_hidden_by_the_glyphs() {
assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs");
assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it");
}

View File

@ -14,7 +14,7 @@ fn doc(path: PathData, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Document
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
let mut b = DocBuilder::new(160.0, 120.0);
b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, |_| {});
b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }, |_| {});
b.build()
}

View File

@ -32,7 +32,7 @@ fn stroke_only(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
st.cap = LineCap::Butt;
f(&mut st);
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }
}
// ---------------------------------------------------------------- gradients

View File

@ -15,7 +15,7 @@ const S: f64 = 4.0;
fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }
}
/// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard.

View File

@ -357,7 +357,7 @@ impl Importer {
usvg::PaintOrder::FillAndStroke => [fill, stroke],
usvg::PaintOrder::StrokeAndFill => [stroke, fill],
};
(Appearance { items: items.into_iter().flatten().collect(), effects: vec![] }, r)
(Appearance { items: items.into_iter().flatten().collect(), ..Default::default() }, r)
}
fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option<Node> {

View File

@ -0,0 +1,63 @@
//! The appearance of groups and layers in SVG export: their own fills and strokes are baked into
//! paths painting the members (below or above them as the contents slot says), their geometry
//! effects into the members, and their raster effects filter the whole group.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
/// A group of a red and a blue square with a green fill of its own (`below`: under the members).
fn doc(below: bool, effects: Vec<Effect>) -> Document {
let mut d = Document::new(200.0, 200.0);
let sq = |d: &mut Document, x: f64, hex: &str| {
let fill = Paint::solid(Color::from_hex(hex).unwrap());
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 40.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)))
};
let (a, b) = (sq(&mut d, 20.0, "#ff0000"), sq(&mut d, 100.0, "#0000ff"));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#00ff00").unwrap()))));
if below {
g.appearance.set_contents_at(1);
}
g.appearance.effects = effects;
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
}
#[test]
fn a_group_fill_exports_as_paths_over_or_under_the_members() {
let svg = export(&doc(false, vec![]), &ExportOptions::default());
// Two green paths, one per member, after both members.
assert_eq!(svg.matches("fill=\"#00ff00\"").count(), 2, "{svg}");
let green = svg.find("#00ff00").unwrap();
assert!(svg.find("#ff0000").unwrap() < green && svg.find("#0000ff").unwrap() < green, "{svg}");
// Below the contents: before them.
let svg = export(&doc(true, vec![]), &ExportOptions::default());
let green = svg.rfind("#00ff00").unwrap();
assert!(green < svg.find("#ff0000").unwrap(), "{svg}");
}
#[test]
fn group_effects_bake_into_the_members_and_filter_the_group() {
let fx = |id: &str, params| Effect { id: id.into(), params, visible: true };
let svg =
export(&doc(false, vec![fx("distort.transform", json!({"moveV": 100})), fx("stylize.dropShadow", json!({}))]), &ExportOptions::default());
// One filter around the whole group (one combined shadow).
assert_eq!(svg.matches("<filter ").count(), 1, "{svg}");
let open = svg.find("<g filter=\"url(#").expect("a filter group");
assert!(svg[open..].contains("#ff0000") && svg[open..].contains("#0000ff") && svg[open..].contains("#00ff00"), "{svg}");
// The members moved down by 100 pt.
let doc = vectorcraft_effects::bake_document(&doc(false, vec![fx("distort.transform", json!({"moveV": 100}))])).unwrap();
let g = &doc.layers[0].children().unwrap()[0];
let b = g.geometric_bounds().unwrap();
assert!((b.y0 - 120.0).abs() < 1e-6, "{b:?}");
// Every baked piece has an id of its own and the group has no fills or effects left.
let mut ids = std::collections::HashSet::new();
g.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id));
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty());
}

View File

@ -14,7 +14,7 @@ fn rect_doc(align_corners: bool) -> Document {
let n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() },
);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();

View File

@ -192,7 +192,7 @@ fn stroke_attributes_roundtrip() {
s.cap = LineCap::Round;
s.join = LineJoin::Bevel;
s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.5, align_corners: false });
let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], effects: vec![] };
let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], ..Default::default() };
let id = d.alloc_id();
let n = Node::path(id, shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 50.0)), ap);
let d = doc_with(vec![n]);
@ -213,7 +213,7 @@ fn stroke_attributes_roundtrip() {
let n = Node::path(
NodeId(99),
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)),
Appearance { items: vec![AppearanceItem::Stroke(m)], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(m)], ..Default::default() },
);
let r = roundtrip(&doc_with(vec![n]));
assert!((art(&r)[0].appearance.stroke().unwrap().miter_limit - 7.0).abs() < 1e-6);
@ -284,7 +284,7 @@ fn multiple_fills_stack_in_paint_order() {
AppearanceItem::Fill(FillLayer::new(solid("#00ff00"))),
AppearanceItem::Stroke(StrokeLayer::new(solid("#0000ff"), 2.0)),
],
effects: vec![],
..Default::default()
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap);
let s = export(&doc_with(vec![n]), &ExportOptions::default());

View File

@ -11,7 +11,7 @@ use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
fn add(d: &mut Document, path: PathData, f: impl FnOnce(&mut StrokeLayer)) {
let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0);
f(&mut st);
let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}

View File

@ -2,10 +2,14 @@
//! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight,
//! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the
//! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar.
//! Groups and layers show a Contents row and type a Characters row where their members
//! (characters) paint among the object's own fills and strokes: double-clicking Contents targets
//! the members, double-clicking Characters edits the characters' paint.
//!
//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into
//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and
//! Shift-click select several fills/strokes for Duplicate and Delete.
//! Rows drag: a fill/stroke row reorders the stack (across the Contents row too), the Contents row
//! moves between the fills and strokes, an effect row reorders its list or moves into another item
//! (dropped on its row) or onto the object; Alt copies instead. Cmd-click and Shift-click select
//! several fills/strokes for Duplicate and Delete.
use std::sync::OnceLock;
@ -35,6 +39,8 @@ pub enum Sel {
Effect(usize),
/// Effect `k` of fill/stroke item `i`.
ItemEffect(usize, usize),
/// The Contents (type: Characters) row: paint edits go to the members (characters).
Contents,
}
impl Sel {
@ -42,7 +48,7 @@ impl Sel {
fn item(self) -> Option<usize> {
match self {
Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i),
Sel::None | Sel::Effect(_) => None,
Sel::None | Sel::Effect(_) | Sel::Contents => None,
}
}
/// `{item, index}` of a selected effect row (`item: null` for the object's own effects).
@ -50,9 +56,13 @@ impl Sel {
match self {
Sel::Effect(k) => Some(json!({"item": null, "index": k})),
Sel::ItemEffect(i, k) => Some(json!({"item": i, "index": k})),
Sel::None | Sel::Item(_) => None,
Sel::None | Sel::Item(_) | Sel::Contents => None,
}
}
/// Is a fill/stroke or effect row selected (what Duplicate and Delete act on)?
fn editable(self) -> bool {
self.item().is_some() || self.effect().is_some()
}
}
/// The selected row of `node` (the first selected object): a selected effect row while that
@ -65,6 +75,7 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) -
(Some(i), Sel::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel,
(Some(i), _) => Sel::Item(i),
(None, Sel::Effect(k)) if exists(None, k) => sel,
(None, Sel::Contents) if owner == Some(n.id) && n.contents_label().is_some() => sel,
(None, _) => Sel::None,
}
}
@ -301,6 +312,8 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dragged {
Item(usize),
/// The Contents (Characters) row.
Contents,
/// Effect `k` of fill/stroke `item` (`None`: of the object).
Effect(Option<usize>, usize),
}
@ -314,6 +327,8 @@ enum Slot {
Effect(Option<usize>, usize),
/// The object row (`top`) or the object's Opacity row.
Object { top: bool },
/// The Contents (Characters) row.
Contents,
}
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) {
@ -330,7 +345,15 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
}
stopped |= resp.drag_stopped();
};
let contents = n.contents_label();
let below = n.appearance.contents_at();
for (i, it) in n.appearance.items.iter().enumerate().rev() {
// The Contents row sits right above the items painted below the members.
if let Some(label) = contents.filter(|_| i + 1 == below) {
let (r, resp) = contents_row(app, ui, n, label, sel);
rows.push((Slot::Contents, r));
track(&resp, Dragged::Contents);
}
let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, items.contains(&i));
rows.push((Slot::Item { index: i, sub: false }, r));
@ -392,6 +415,11 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
}
}
}
if let Some(label) = contents.filter(|_| below == 0) {
let (r, resp) = contents_row(app, ui, n, label, sel);
rows.push((Slot::Contents, r));
track(&resp, Dragged::Contents);
}
// Object-level effects.
for (k, e) in n.appearance.effects.iter().enumerate() {
let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
@ -415,7 +443,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
(Dragged::Effect(..), _) => {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside);
}
// Onto the Contents row: an item from above lands just below it, one from below just above.
(Dragged::Item(from), Slot::Contents) => line(if from >= below { r.bottom() } else { r.top() }),
(Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }),
(Dragged::Contents, _) => line(if contents_slot(n, slot).is_some_and(|k| k > below) { r.top() } else { r.bottom() }),
}
}
if !ui.ctx().input(|i| i.pointer.any_down()) {
@ -439,12 +470,27 @@ fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> {
}
/// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item;
/// the object row stands above the topmost item, the rows below the items under item 0.
/// the object row stands above the topmost item, the rows below the items under item 0, the
/// Contents row for the first item above it.
fn item_at(n: &Node, slot: Slot) -> usize {
match slot {
Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index,
Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1),
Slot::Object { top: false } | Slot::Effect(None, _) => 0,
Slot::Contents => n.appearance.contents_at(),
}
}
/// How many items paint below the Contents row after it is dropped on row `slot`: it goes above a
/// fill or stroke above it and below one below it; the object row is above everything, the rows
/// under the stack below everything. `None` on itself.
fn contents_slot(n: &Node, slot: Slot) -> Option<usize> {
let k = n.appearance.contents_at();
match slot {
Slot::Item { index, .. } | Slot::Effect(Some(index), _) => Some(if index >= k { index + 1 } else { index }),
Slot::Object { top: true } => Some(n.appearance.items.len()),
Slot::Object { top: false } | Slot::Effect(None, _) => Some(0),
Slot::Contents => None,
}
}
@ -459,16 +505,38 @@ fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, c
// The copy goes where the row was dropped: above that row when dragged up, below it
// when dragged down.
let at = if to >= from { to + 1 } else { to };
Some(("appearance.duplicateItem", json!({"index": from, "to": at})))
} else {
(to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to})))
return Some(("appearance.duplicateItem", json!({"index": from, "to": at})));
}
// The Contents row ends up below an item dropped on the object row, above one dropped
// under the stack, and on the far side of one dropped on it (elsewhere it stays put).
let k = n.appearance.contents_at();
let others = k - usize::from(from < k);
let contents = match slot {
Slot::Contents if from < k => Some(others),
Slot::Contents | Slot::Object { top: false } | Slot::Effect(None, _) => Some(others + 1),
Slot::Object { top: true } => Some(others),
_ => None,
}
.filter(|_| n.contents_label().is_some());
let to = if slot == Slot::Contents { others } else { to };
if to == from && contents.is_none_or(|c| c == k) {
return None;
}
let mut params = json!({"from": from, "to": to});
if let Some(c) = contents {
params["contents"] = json!(c);
}
Some(("appearance.moveItem", params))
}
Dragged::Contents => {
let to = contents_slot(n, slot)?;
(to != n.appearance.contents_at()).then(|| ("appearance.moveItem", json!({"from": "contents", "to": to})))
}
Dragged::Effect(from_item, k) => {
let (item, pos) = match slot {
Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)),
Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()),
Slot::Object { .. } => (None, n.appearance.effects.len()),
Slot::Object { .. } | Slot::Contents => (None, n.appearance.effects.len()),
};
// Within its own list the effect leaves its old place first.
let same = item == from_item && !copy;
@ -481,6 +549,34 @@ fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, c
}
}
/// The Contents (type: Characters) row of `n`: clicking it sends paint edits to the members
/// (characters); double-clicking Contents targets the members, Characters opens the Color panel
/// on the characters' paint (whose chips it shows). Returns its rect and response (for dragging).
fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, sel: Sel) -> (Rect, egui::Response) {
let (r, resp) = row(ui, sel == Sel::Contents);
eye(ui, r, "ap-contents-eye", true, false);
text(ui, pos2(r.left() + EYE_W + 24.0, r.center().y), label, false);
let characters = matches!(n.kind, vectorcraft_doc::NodeKind::Text(_));
if characters {
for (k, stroke) in [false, true].into_iter().enumerate() {
let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 100.0 + 24.0 * k as f32, r.center().y - 9.0), vec2(18.0, 18.0));
chip(ui, cr, n.proxy_paint(stroke, None).map_or(&Paint::None, |(p, ..)| p));
}
}
let resp = resp.on_hover_text(if characters { "Double-click to edit the characters' paint" } else { "Double-click to target the contents" });
if resp.clicked() || resp.double_clicked() {
select_row(app, ui.ctx(), Sel::Contents);
}
if resp.double_clicked() {
if characters {
app.ui.open_panel = Some("color".into());
} else {
app.run("appearance.targetContents", json!({})).ok();
}
}
(r, resp)
}
/// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn).
fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
let t = Tokens::get(ui.ctx());
@ -686,7 +782,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
app.run("appearance.clear", json!({})).ok();
}
let can = has && sel != Sel::None;
let can = has && sel.editable();
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() {
duplicate_selected(app, ui, node, sel);
}
@ -753,10 +849,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("appearance.addStroke", json!({})).ok();
}
ui.separator();
if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) {
if menu_item(ui, "Duplicate Item", has && sel.editable(), false) {
duplicate_selected(app, ui, node.as_ref(), sel);
}
if menu_item(ui, "Remove Item", has && sel != Sel::None, false) {
if menu_item(ui, "Remove Item", has && sel.editable(), false) {
delete_selected(app, ui, node.as_ref(), sel);
}
if menu_item(ui, "Clear Appearance", has, false) {
@ -840,4 +936,77 @@ mod tests {
assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false));
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
}
/// One headless frame of the panel: the texts drawn, top to bottom.
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) {
match s {
egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, t)| t).collect()
}
#[test]
fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "object.group", json!({}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "appearance.addStroke", json!({}));
let rows =
|t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::<Vec<_>>();
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]);
run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1}));
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]);
let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
assert_eq!(rows(frame(&mut app)), ["Characters"]);
run(&mut app, "appearance.addFill", json!({}));
assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]);
}
#[test]
fn drops_move_items_across_the_contents_row_and_the_row_itself() {
use vectorcraft_doc::FillLayer;
// A group [F0 | F1 F2]: the Contents row sits above item 0.
let mut n = Node::group(NodeId(1), vec![]);
n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect();
n.appearance.set_contents_at(1);
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Contents,
Slot::Item { index: 0, sub: false },
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0);
let drop = |what, i| drop_command(&n, &rows, what, at(i), false);
// Onto the Contents row: from above it lands just below, from below just above.
assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2}))));
assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0}))));
// Onto the object row: above everything; under the stack: below everything.
assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0}))));
assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2}))));
// The row itself: above item 2, below item 0; nowhere new on itself.
assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3}))));
assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0}))));
assert_eq!(drop(Dragged::Contents, 3), None);
assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2}))));
}
}

View File

@ -283,3 +283,24 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh
{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}}
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}}
```
## Group, layer and type appearance
Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes
paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a
Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack:
fills and strokes above it paint over the members (characters), those below under them; new items go above it.
`appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes
`contents` to say where the row ends up in the same undo step; `document.node` shows the slot as
`appearance.contents_index`. The `appearance.*`, `effect.*` and `graphicStyle.apply` commands edit the selected
objects themselves (a group's own stack; layers through `ids`) or, with `target: "contents"`, the objects inside the
groups and layers. `appearance.targetContents` selects a group's members (double-clicking the Contents row).
SVG and PDF export bake a group's own fills, strokes and geometry effects into paths; its raster effects stay a
filter on the whole group.
```json
{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}}
{"name":"run_command","arguments":{"command":"appearance.moveItem","params":{"ids":[7],"from":"contents","to":1}}}
{"name":"run_command","arguments":{"command":"effect.apply","params":{"ids":[2],"effect":"stylize.dropShadow"}}}
{"name":"run_command","arguments":{"command":"appearance.addStroke","params":{"ids":[7],"target":"contents"}}}
```