M3.70: Expand Appearance turns fills, strokes, effects and brushes into objects
effect.expandAppearance now expands the whole appearance: each visible fill becomes a path of its own and each stroke its outline (a brushed stroke its brush art), grouped in paint order under the object's id and transparency; geometry effects are baked and raster effects become an embedded image (shadows below the art, blur/feather/inner glow replacing it). Type with effects or its own fills is outlined, and a group's or layer's own fills and strokes are expanded among its members. The command is enabled whenever an appearance isn't basic. Builds on bake_leaf (effects::expand_leaf / expand_art) and shares the raster-effect image code with PDF export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b768b4a53b
commit
b4ba4ecf0a
|
|
@ -6,13 +6,16 @@
|
|||
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
|
||||
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
|
||||
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::group::has_own_paint;
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::{GeomContext, apply_geometry_with, evaluate_container, has_geometry, has_pathfinder, is_geometry, needs_outline, reshape};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
|
|
@ -126,18 +129,48 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
|
|||
}
|
||||
}
|
||||
|
||||
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
|
||||
/// none of `n`'s transparency.
|
||||
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
|
||||
let id = d.alloc_id();
|
||||
let kind = path_kind(d, path, rule);
|
||||
Node {
|
||||
id,
|
||||
name: None,
|
||||
appearance: Appearance { items: vec![item], ..Default::default() },
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
knockout_shape: false,
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
kind,
|
||||
..n.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
|
||||
/// its effects, and `n` without those effects.
|
||||
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
let (base, rule) = geometry(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = apply(&n.appearance.effects, &base, &ctx);
|
||||
let mut m = n.clone();
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some((g, rule, ctx, m))
|
||||
}
|
||||
|
||||
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) {
|
||||
m.kind = path_kind(d, g, rule);
|
||||
return Some(m);
|
||||
}
|
||||
// Per-item geometry: one path per fill/stroke, grouped under the object's transparency and
|
||||
// raster effects (what Expand Appearance produces).
|
||||
// raster effects.
|
||||
let children = n
|
||||
.appearance
|
||||
.items
|
||||
|
|
@ -146,24 +179,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
|||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
clear_item_effects(&mut it);
|
||||
let id = d.alloc_id();
|
||||
let kind = path_kind(d, ig, rule);
|
||||
Arc::new(Node {
|
||||
id,
|
||||
name: None,
|
||||
appearance: Appearance { items: vec![it], ..Default::default() },
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
knockout_shape: false,
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
kind,
|
||||
..n.clone()
|
||||
})
|
||||
Arc::new(piece(d, n, ig, rule, it))
|
||||
})
|
||||
.collect();
|
||||
m.appearance.items.clear();
|
||||
|
|
@ -171,6 +187,75 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
|||
Some(m)
|
||||
}
|
||||
|
||||
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
|
||||
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
|
||||
/// paint, or its brush art. `None` when it paints nothing.
|
||||
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
|
||||
|
||||
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
|
||||
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
|
||||
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
|
||||
/// its own), in paint order, as the members of a group that keeps
|
||||
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
m.appearance.effects.retain(|e| e.visible);
|
||||
let mut pieces = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| paints(i)) {
|
||||
let ig = apply(item.effects(), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
clear_item_effects(&mut it);
|
||||
it.effects_mut().retain(|e| e.visible);
|
||||
let p = match &it {
|
||||
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
|
||||
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
|
||||
};
|
||||
pieces.extend(p.map(hoist_transparency));
|
||||
}
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
match pieces.as_slice() {
|
||||
[one] if m.appearance.effects.is_empty() && one.has_default_transparency() && one.appearance.effects.is_empty() => {
|
||||
m.kind = one.kind.clone();
|
||||
m.appearance = one.appearance.clone();
|
||||
}
|
||||
_ => m.kind = NodeKind::Group { children: pieces.into_iter().map(Arc::new).collect(), clip: false },
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `p` with the opacity and blend mode of its only fill or stroke as its own (the same look, and a
|
||||
/// basic appearance).
|
||||
fn hoist_transparency(mut p: Node) -> Node {
|
||||
if p.has_default_transparency()
|
||||
&& let [item] = p.appearance.items.as_mut_slice()
|
||||
{
|
||||
let (opacity, blend) = match item {
|
||||
AppearanceItem::Fill(f) => (&mut f.opacity, &mut f.blend),
|
||||
AppearanceItem::Stroke(s) => (&mut s.opacity, &mut s.blend),
|
||||
};
|
||||
p.opacity = std::mem::replace(opacity, 1.0);
|
||||
p.blend = std::mem::take(blend);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a
|
||||
/// group's own fills and strokes or outlined type), in place.
|
||||
pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
if let Some(m) = expand_leaf(d, n, stroke_art) {
|
||||
*n = m;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
expand_art(d, Arc::make_mut(c), stroke_art);
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
|
||||
/// document has none (export it as is).
|
||||
pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ fn is_container(n: &Node) -> bool {
|
|||
}
|
||||
|
||||
/// Does `item` paint anything?
|
||||
fn paints(item: &AppearanceItem) -> bool {
|
||||
pub(crate) fn paints(item: &AppearanceItem) -> bool {
|
||||
item.visible()
|
||||
&& !item.paint().is_none()
|
||||
&& match item {
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ use serde_json::{Map, Value, json};
|
|||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use bake::{bake_document, fresh_ids, needs_bake};
|
||||
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children,
|
||||
};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
|
|
|
|||
|
|
@ -113,10 +113,26 @@ fn outline_nested_text(n: &mut Node) {
|
|||
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
|
||||
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
if fx.is_empty() || !needs_outline(n) {
|
||||
if geometry_effects(n).is_empty() || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// [`reshape`] without its checks (no geometry effects: just the art).
|
||||
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Effect, Node, NodeKind};
|
||||
use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
|
|
@ -58,8 +58,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?: [..], target?} bake geometry effects into the paths and drop them (raster effects stay live); a group's or layer's own fills and strokes become paths among its members → {ids}",
|
||||
has_doc,
|
||||
"{ids?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
cmd!(
|
||||
|
|
@ -247,71 +247,202 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
/// Geometry of a path or compound path node.
|
||||
fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
// ---------- Expand Appearance ----------
|
||||
|
||||
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
|
||||
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
|
||||
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
|
||||
/// fills or strokes of its own?
|
||||
fn expandable(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
}
|
||||
_ => None,
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bake the object-level geometry effects of `id` (and, recursively, of group members). A group's
|
||||
/// or layer's Pathfinder and geometry effects and its own fills and strokes become its members.
|
||||
fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
if let Some(mut m) = effects::evaluate_container(&n) {
|
||||
let mut seen = std::collections::HashSet::from([m.id]);
|
||||
for c in m.children_mut().into_iter().flatten() {
|
||||
effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
|
||||
}
|
||||
let Some(slot) = d.node_mut(id) else { return };
|
||||
*slot = m;
|
||||
out.push(id);
|
||||
fn can_expand(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select (or target) objects whose appearance isn't basic".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
|
||||
}
|
||||
|
||||
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
|
||||
/// everything inside it.
|
||||
fn strip_raster(n: &mut Node, members: bool) {
|
||||
let keep = |e: &Effect| !effects::is_raster(&e.id);
|
||||
n.appearance.effects.retain(keep);
|
||||
for it in &mut n.appearance.items {
|
||||
it.effects_mut().retain(keep);
|
||||
}
|
||||
if members {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
strip_raster(Arc::make_mut(c), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at
|
||||
/// the document's raster effects resolution, with whether they all paint below it (shadows and
|
||||
/// outer glows: the image then holds just them). A group's or layer's image leaves out its
|
||||
/// members' raster effects when those are expanded with them. `None` when it has none.
|
||||
fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
|
||||
let fx: Vec<effects::RasterFx> = std::iter::once(&n.appearance.effects)
|
||||
.chain(n.appearance.items.iter().map(|i| i.effects()))
|
||||
.flat_map(|e| effects::raster_effects(e))
|
||||
.collect();
|
||||
if fx.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let below = fx.iter().all(effects::RasterFx::is_below);
|
||||
// Its transparency stays on the expanded object.
|
||||
let mut whole = n.clone();
|
||||
(whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None);
|
||||
if below && is_container(&whole) {
|
||||
for c in whole.children_mut().into_iter().flatten() {
|
||||
strip_raster(Arc::make_mut(c), true);
|
||||
}
|
||||
}
|
||||
let bare = below.then(|| {
|
||||
let mut b = whole.clone();
|
||||
strip_raster(&mut b, false);
|
||||
b
|
||||
});
|
||||
let scale = super::rasterfx::effects_scale(d);
|
||||
let image = super::rasterfx::effect_image(d, &whole, bare.as_ref(), scale)?;
|
||||
Some((image, below))
|
||||
}
|
||||
|
||||
/// `m` (an expanded object) with `image` of its shadows and outer glows painted below its art: the
|
||||
/// image goes first in a group or layer (after a layer's clipping path), anything else is grouped
|
||||
/// with it under its id, name and transparency.
|
||||
fn put_below(d: &mut Document, m: &mut Node, image: Node) {
|
||||
match &mut m.kind {
|
||||
NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)),
|
||||
NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)),
|
||||
_ => {
|
||||
let mut inner = m.clone();
|
||||
inner.id = d.alloc_id();
|
||||
(inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None);
|
||||
inner.knockout = Default::default();
|
||||
inner.knockout_shape = false;
|
||||
m.appearance = Default::default();
|
||||
m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that
|
||||
/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with
|
||||
/// `all` every member.
|
||||
fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) {
|
||||
let id = m.id;
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
for c in m.children_mut().into_iter().flatten() {
|
||||
let own = all || c.id == id;
|
||||
let c = Arc::make_mut(c);
|
||||
effects::fresh_ids(d, c, &mut seen);
|
||||
if own {
|
||||
effects::expand_art(d, c, stroke_art);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
let container = is_container(&n);
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
}
|
||||
let image = raster_image(d, &n);
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut stroke_art =
|
||||
|d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st);
|
||||
let expanded = match image {
|
||||
// Blur, feather and inner glow change the object itself: it becomes the image (a layer
|
||||
// keeps it as its only member).
|
||||
Some((image, false)) => Some(if n.is_layer() {
|
||||
let mut m = v;
|
||||
m.appearance = Default::default();
|
||||
m.set_clips(false);
|
||||
if let Some(ch) = m.children_mut() {
|
||||
*ch = vec![Arc::new(image)];
|
||||
}
|
||||
m
|
||||
} else {
|
||||
Node { kind: image.kind, appearance: Default::default(), ..v }
|
||||
}),
|
||||
image => {
|
||||
let m = if container {
|
||||
// A group's or layer's own fills and strokes become art among its members.
|
||||
effects::evaluate_container(&v).map(|mut m| {
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects: through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
|
||||
expand_pieces(d, &mut m, true, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
};
|
||||
match image {
|
||||
Some((image, _)) => {
|
||||
let mut m = m.unwrap_or(v);
|
||||
put_below(d, &mut m, image);
|
||||
Some(m)
|
||||
}
|
||||
None => m,
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(m) = expanded
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
out.push(id);
|
||||
}
|
||||
if container {
|
||||
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
for c in children {
|
||||
expand_node(d, c, out);
|
||||
expand_node(d, c, brushes, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !effects::has_geometry(&n.appearance.effects) {
|
||||
return;
|
||||
}
|
||||
let Some((path, rule)) = node_geometry(&n) else { return };
|
||||
let Some(bounds) = path.bounds() else { return };
|
||||
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n));
|
||||
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
|
||||
let children = baked
|
||||
.subpaths
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let cid = d.alloc_id();
|
||||
Arc::new(Node::path(cid, PathData::single(sp), Default::default()))
|
||||
})
|
||||
.collect();
|
||||
NodeKind::Compound { children, rule }
|
||||
} else {
|
||||
NodeKind::Path { path: baked, rule, live: None, clipping: false, guide: false }
|
||||
};
|
||||
let Some(m) = d.node_mut(id) else { return };
|
||||
m.kind = kind;
|
||||
m.appearance.effects.retain(|e| !effects::is_geometry(&e.id));
|
||||
out.push(id);
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &mut out);
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Expand Appearance: no geometry effects to expand".into()));
|
||||
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
|
|
@ -370,16 +501,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_bakes_geometry_and_keeps_raster() {
|
||||
fn expand_appearance_bakes_geometry() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
// One fill: the path itself, reshaped.
|
||||
let n = node(&s, id);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.width() - 120.0).abs() < 0.1, "{b:?}");
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
assert_eq!(n.appearance.effects[0].id, "stylize.dropShadow");
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }));
|
||||
// Nothing left to expand.
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
|
||||
|
|
@ -388,6 +519,7 @@ mod tests {
|
|||
#[test]
|
||||
fn expand_transform_copies_makes_compound() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let n = node(&s, id);
|
||||
|
|
|
|||
|
|
@ -460,7 +460,13 @@ fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMo
|
|||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
|
||||
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
|
||||
/// and effects. `None` when it paints nothing.
|
||||
fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, st: &StrokeLayer) -> Option<Node> {
|
||||
pub(crate) fn outlined_stroke(
|
||||
d: &mut Document,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
path: &PathData,
|
||||
rule: FillRule,
|
||||
st: &StrokeLayer,
|
||||
) -> Option<Node> {
|
||||
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
|
||||
let art = match pieces.len() {
|
||||
|
|
|
|||
|
|
@ -73,42 +73,65 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64) -> vectorc
|
|||
r.render_region(&tmp, region, scale, false)
|
||||
}
|
||||
|
||||
/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`.
|
||||
fn flatten_node(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
|
||||
let fx = raster_fx(n);
|
||||
let b = n.visual_bounds()?;
|
||||
let reach = effects::outset(&n.appearance.effects) + 2.0;
|
||||
/// Pixels per point of raster effects rendered as images (the document's raster effects
|
||||
/// resolution).
|
||||
pub(crate) fn effects_scale(doc: &Document) -> f64 {
|
||||
(doc.raster_effects_ppi / 72.0).clamp(1.0 / 72.0, 2400.0 / 72.0)
|
||||
}
|
||||
|
||||
/// An embedded image (a new node of `out`, its pixels in `out.images`) of `whole` rendered alone
|
||||
/// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects
|
||||
/// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out
|
||||
/// of the image, which then only holds what the effects add around it (a shadow to go under the
|
||||
/// vector object).
|
||||
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
|
||||
let b = whole.visual_bounds()?;
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) + 2.0;
|
||||
let b = b.inflate(reach, reach);
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
let below_only = fx.iter().all(RasterFx::is_below);
|
||||
let mut whole = n.clone();
|
||||
whole.blend = vectorcraft_doc::color::BlendMode::Normal;
|
||||
let mut img = render(src, vec![whole.clone()], region, scale);
|
||||
let mut vector = None;
|
||||
if below_only {
|
||||
// Knock the object's own coverage out of the image; the vector object goes on top.
|
||||
let mut bare = whole.clone();
|
||||
bare.appearance.effects.retain(|e| !effects::is_raster(&e.id));
|
||||
bare.opacity = 1.0;
|
||||
let obj = render(src, vec![bare], region, scale);
|
||||
let mut img = render(out, vec![whole.clone()], region, scale);
|
||||
if let Some(bare) = knockout {
|
||||
let obj = render(out, vec![bare.clone()], region, scale);
|
||||
for (px, o) in img.pixels.chunks_exact_mut(4).zip(obj.pixels.chunks_exact(4)) {
|
||||
let keep = 1.0 - o[3] as f32 / 255.0;
|
||||
for c in px.iter_mut() {
|
||||
*c = (*c as f32 * keep).round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = out.alloc_id();
|
||||
let mut key = format!("raster-effect-{}", id.0);
|
||||
while out.images.contains_key(&key) {
|
||||
key.push('+');
|
||||
}
|
||||
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(img.to_png()) });
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
|
||||
image.name = Some("Raster effect".into());
|
||||
Some(image)
|
||||
}
|
||||
|
||||
/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`.
|
||||
fn flatten_node(out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
|
||||
let below_only = raster_fx(n).iter().all(RasterFx::is_below);
|
||||
let mut whole = n.clone();
|
||||
whole.blend = vectorcraft_doc::color::BlendMode::Normal;
|
||||
// Shadows only: knock the object's own coverage out of the image; the vector object goes on top.
|
||||
let bare = below_only.then(|| {
|
||||
let mut bare = whole.clone();
|
||||
bare.appearance.effects.retain(|e| !effects::is_raster(&e.id));
|
||||
bare.opacity = 1.0;
|
||||
bare
|
||||
});
|
||||
let mut image = effect_image(out, &whole, bare.as_ref(), scale)?;
|
||||
let vector = bare.map(|_| {
|
||||
let mut v = n.clone();
|
||||
v.appearance.effects.retain(|e| !effects::is_raster(&e.id));
|
||||
v.blend = vectorcraft_doc::color::BlendMode::Normal;
|
||||
vector = Some(v);
|
||||
}
|
||||
let key = format!("raster-effect-{}", n.id.0);
|
||||
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(img.to_png()) });
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut image = Node::new(out.alloc_id(), NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
|
||||
image.name = Some("Raster effect".into());
|
||||
v
|
||||
});
|
||||
Some(match vector {
|
||||
Some(v) => {
|
||||
let mut g = Node::group(out.alloc_id(), vec![Arc::new(image), Arc::new(v)]);
|
||||
|
|
@ -123,15 +146,15 @@ fn flatten_node(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Opt
|
|||
})
|
||||
}
|
||||
|
||||
fn walk(src: &Document, out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
|
||||
fn walk(out: &mut Document, n: &Node, scale: f64) -> Option<Node> {
|
||||
if !needs(n) || !n.visible {
|
||||
return None;
|
||||
}
|
||||
if !raster_fx(n).is_empty() {
|
||||
return flatten_node(src, out, n, scale);
|
||||
return flatten_node(out, n, scale);
|
||||
}
|
||||
let mut m = n.clone();
|
||||
let ch: Vec<Arc<Node>> = n.children()?.iter().map(|c| walk(src, out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
|
||||
let ch: Vec<Arc<Node>> = n.children()?.iter().map(|c| walk(out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
|
||||
if let Some(slot) = m.children_mut() {
|
||||
*slot = ch;
|
||||
}
|
||||
|
|
@ -147,10 +170,10 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
|||
// Geometry effects first, so the vector objects kept above shadows are final.
|
||||
let baked = effects::bake_document(doc);
|
||||
let src = baked.as_ref().unwrap_or(doc);
|
||||
let scale = (src.raster_effects_ppi / 72.0).clamp(1.0 / 72.0, 2400.0 / 72.0);
|
||||
let scale = effects_scale(src);
|
||||
let mut out = src.clone();
|
||||
let layers = src.layers.clone();
|
||||
out.layers = layers.iter().map(|l| walk(src, &mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
|
||||
out.layers = layers.iter().map(|l| walk(&mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -787,6 +787,8 @@ mod tests_editcolors;
|
|||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_expand;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
|
|
|
|||
|
|
@ -140,8 +140,12 @@ fn group_appearance_round_trips_and_expands() {
|
|||
assert!(n.appearance.items.is_empty() && n.appearance.effects.is_empty());
|
||||
let ch = n.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "fill, two members, stroke");
|
||||
// Each is a group of one path per member (sharing the item's opacity and blend mode).
|
||||
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
|
||||
// stroke's paths are its outlines, filled.
|
||||
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
|
||||
assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
assert!((n.geometric_bounds().unwrap().y0 - 70.0).abs() < 1e-6);
|
||||
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
|
||||
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
|
||||
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster
|
||||
//! effects as images, brushes as art, and the enable rule.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke.
|
||||
fn square(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(s, "stroke.set", json!({"weight": 4}));
|
||||
id
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
|
||||
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
|
||||
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
|
||||
}
|
||||
|
||||
fn expandable(s: &Session) -> bool {
|
||||
s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
assert!(!expandable(&s), "a basic appearance has nothing to expand");
|
||||
// A second fill on top (half-transparent green) and an outset stroke.
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1}));
|
||||
assert!(expandable(&s));
|
||||
let before = render(&s);
|
||||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||||
assert_eq!(r["ids"], json!([id.0]));
|
||||
let n = node(&s, id);
|
||||
let ch = n.children().expect("a group of the fills and the stroke");
|
||||
assert_eq!(ch.len(), 3);
|
||||
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
|
||||
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
|
||||
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
|
||||
let ring = ch[1].geometric_bounds().unwrap();
|
||||
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
|
||||
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
|
||||
assert!(ch[2].appearance.is_basic());
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||||
assert!(!expandable(&s));
|
||||
// One undo step.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Path { .. }));
|
||||
assert_eq!(node(&s, id).appearance.items.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"}));
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, id);
|
||||
assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply);
|
||||
assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty());
|
||||
let ch = n.children().unwrap();
|
||||
assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke");
|
||||
let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) };
|
||||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
|
||||
assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty()));
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24));
|
||||
// The shadow is still there, below and right of the square.
|
||||
let p = after.pixel(155, 155);
|
||||
assert!(p[0] < 200, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blur_turns_the_object_into_an_image() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label());
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_art_brush_becomes_a_group_of_its_art() {
|
||||
let mut s = session();
|
||||
let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||||
run(&mut s, "brush.apply", json!({"name": "Charcoal"}));
|
||||
assert!(expandable(&s));
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
|
||||
let mut brushed = false;
|
||||
n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c));
|
||||
assert!(!brushed, "no brush strokes are left");
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(vectorcraft_brush::has_brush(&node(&s, id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
|
||||
let mut s = session();
|
||||
let a = square(&mut s);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"item": 0, "weight": 6}));
|
||||
assert!(expandable(&s));
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let l = node(&s, layer);
|
||||
assert!(l.appearance.items.is_empty());
|
||||
let ch = l.children().unwrap();
|
||||
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
|
||||
assert_eq!(ch[0].id, a);
|
||||
// The layer's stroke is outlined: a filled path.
|
||||
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
|
||||
let mut s = session();
|
||||
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, t);
|
||||
let ch = n.children().unwrap();
|
||||
assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_)));
|
||||
// A stroke of its own: outlined glyphs.
|
||||
let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [u.0]}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let mut text = false;
|
||||
node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
|
||||
assert!(!text, "the type is outlined");
|
||||
assert!(!expandable(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
|
||||
let mut s = session();
|
||||
square(&mut s);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let l = node(&s, layer);
|
||||
assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty());
|
||||
let ch = l.children().unwrap();
|
||||
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
|
||||
}
|
||||
15
docs/mcp.md
15
docs/mcp.md
|
|
@ -413,3 +413,18 @@ is edited the panel lists only an `<Opacity Mask>` entry and the document tab sa
|
|||
{"name":"run_command","arguments":{"command":"transparency.set","params":{"opacity":50}}}
|
||||
{"name":"run_command","arguments":{"command":"appearance.transfer","params":{"source":12,"target":2,"copy":true}}}
|
||||
```
|
||||
|
||||
## Expand Appearance
|
||||
|
||||
`effect.expandAppearance {ids?, target?}` (Object → Expand Appearance, enabled when a selected or targeted object's appearance isn't basic)
|
||||
turns appearances into plain objects in one undo step: each visible fill becomes a copy of the path painted by that
|
||||
fill alone and each stroke its outline filled with its paint (a brushed stroke: its brush art), grouped in paint order
|
||||
under the object's id; each piece takes its fill's or stroke's opacity and blend mode, and the group keeps the
|
||||
object's transparency and opacity mask. Geometry effects are baked; raster effects become an embedded image at the
|
||||
document's raster effects resolution (shadows and outer glows under the art; a blur, feather or inner glow replaces
|
||||
the object with the image). Type with effects or fills and strokes of its own is outlined. A group's or layer's own
|
||||
fills and strokes become objects among its members, and its members are expanded too.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue