Merge pull request #26 from storytold/pr/m3-container-appearance
M3: group, layer and type appearance with Contents and Characters rows (M3.68)
This commit is contained in:
commit
1ea13b54d3
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, &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, ¶ms, &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 } => {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
docs/mcp.md
21
docs/mcp.md
|
|
@ -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"}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue