Files
vectorcraft/crates/effects/src/group.rs
T
dotCooCoo 25a99d7c07 Effects: raster effects before a Transform effect past the budget stay with the art
A Transform effect whose result would pass the geometry budget (2 million
anchors or 8 million lines drawn) is left out and the art stays where it
was, but the raster effects listed before it still scaled and moved with
it: before a Transform effect that scales the art 1000 times, a Drop
Shadow's offset grew 1000 times and its blur went to the 1000 pt limit
around art that had not moved, and Expand kept those values.

The geometry pass now notes each Transform effect that moves the art once
and that it leaves out, in the Carry it already returns, and
Carry::hide_skipped hides those effects in the list the raster effects
are read from. The canvas (a path's own effects and its fills' and
strokes'), its bounds, bake (SVG and PDF export), shape art, Expand,
Expand Appearance and Outline Stroke use it, and a fill's Transform
effect inside a piece no longer scales the effects before it when the
budget leaves it out. The budget itself is unchanged.

A path drawn as its own art inside a group whose geometry effects run
after its members' own read its object effects whole. The reshaping pass
now notes a Transform effect that no path in the art took past the
budget, and the member's art hides it before its raster effects are read.

The bounds skip the extra geometry pass when no raster effect could
follow a Transform effect, and painted_reach runs it once for both
measurements.
2026-11-13 09:16:26 -08:00

918 lines
42 KiB
Rust

//! The appearance of groups and layers.
//!
//! **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. Trap ([`crate::trap`]) keeps the
//! children and adds its traps above them.
//!
//! **Compound shapes** ([`compound_shape_area`]): a compound shape draws as the one area its
//! members' shape modes make, painted by its own appearance as a group's own fills and strokes
//! paint its members.
//!
//! **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 serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
use crate::reshape::SymbolNodes;
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
("pathfinder.intersect", "Intersect", PathfinderOp::Intersect),
("pathfinder.exclude", "Exclude", PathfinderOp::Exclude),
("pathfinder.subtract", "Subtract", PathfinderOp::MinusFront),
("pathfinder.minusBack", "Minus Back", PathfinderOp::MinusBack),
("pathfinder.divide", "Divide", PathfinderOp::Divide),
("pathfinder.trim", "Trim", PathfinderOp::Trim),
("pathfinder.merge", "Merge", PathfinderOp::Merge),
("pathfinder.crop", "Crop", PathfinderOp::Crop),
("pathfinder.outline", "Outline", PathfinderOp::Outline),
("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix),
("pathfinder.softMix", "Soft Mix", PathfinderOp::SoftMix),
];
/// Is `id` an Effect → Pathfinder effect?
pub fn is_pathfinder(id: &str) -> bool {
id.starts_with("pathfinder.")
}
fn op_of(id: &str) -> Option<PathfinderOp> {
PATHFINDER_EFFECTS.iter().find(|e| e.0 == id).map(|e| e.2)
}
/// Does `n` (a group or layer) carry a visible Pathfinder effect, or is it a compound shape?
pub fn has_pathfinder(n: &Node) -> bool {
n.is_compound_shape()
|| (matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id)))
}
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as,
/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
/// the art it draws, and type and symbol instances as their art through `hook`. Past
/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc` ([`shape_art`]).
pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
let mut out = vec![];
leaves(doc, n, hook, &SymbolNodes::default(), &mut out);
out
}
/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
fn leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
if !n.visible {
return;
}
// A blend, an envelope or an object with a warp takes part as the art it draws.
if let Some(art) = shape_art_in(doc, n, hook, nodes) {
let first = out.len();
leaves(doc, &art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art, and every piece of a path
// whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and
// blend mode.
let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. });
if crate::needs_outline(n) || pieces {
for l in out.iter_mut().skip(first) {
within(l, n);
}
}
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
leaves(doc, c, hook, nodes, out);
}
}
},
_ => {
// Every path of the outline art, at any depth, is drawn inside the object's opacity
// and blend mode. A symbol's art comes with the live objects and the instances of
// other symbols inside it given as their art, and the type inside it outlined, so the
// art has nothing left for `hook` to give.
if let Some(h) = hook
&& let Some(art) = h(n)
{
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(doc, &mut art, false);
transparency_into_members(&mut art);
art
}
NodeKind::SymbolInstance { .. } => return,
_ => art,
};
let first = out.len();
leaves(doc, &art, None, nodes, out);
for l in out.iter_mut().skip(first) {
within(l, n);
}
}
}
}
}
/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is
/// not Normal.
fn within(l: &mut Node, n: &Node) {
drawn_in(l, n.opacity, n.blend);
}
/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and
/// `blend` when it is not Normal.
fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) {
l.opacity *= opacity;
if blend != BlendMode::Normal {
l.blend = blend;
}
}
/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not
/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so
/// that each path of `n` carries the transparency it is drawn with.
fn transparency_into_members(n: &mut Node) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if !is_container(c) || c.is_compound_shape() {
continue;
}
let c = Arc::make_mut(c);
let (opacity, blend) = (c.opacity, c.blend);
if let Some(inner) = c.children_mut() {
for g in inner.iter_mut() {
drawn_in(Arc::make_mut(g), opacity, blend);
}
}
(c.opacity, c.blend) = (1.0, BlendMode::Normal);
transparency_into_members(c);
}
}
/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that
/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art,
/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or
/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer
/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without
/// one, type is outlined and symbol instances stay as they are);
/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
/// for symbol instances without geometry effects, and for one without art from `hook`. A
/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's
/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces
/// that name one are refused and the path stays as it is).
pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`shape_art`], with the symbol art given so far counted in `nodes`.
fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(doc, n, hook, nodes)?;
transparency_into_members(&mut art);
Some(art)
}
/// [`shape_art_in`] before the groups inside the art pass on their transparency.
fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let fx = crate::reshape::geometry_effects(n);
let outline = hook.unwrap_or(&crate::outline_text);
match &n.kind {
// A path with a live-effect plug-in that paints its pieces: the group of its pieces, each
// with its own paints, as the renderer draws it.
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => {
let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten();
Some(painted.unwrap_or_else(|| reshaped(n, n, doc)))
}
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) {
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
// The members' own effects run first, as on the canvas.
members_as_drawn(doc, &mut m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes follow the effects as its members do.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
without_geometry_effects(&mut m);
Some(m)
}
},
// A symbol instance with a warp: its symbol's art placed by the instance, with the type
// inside it outlined, reshaped as one piece, as the renderer draws it.
NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => {
let h = hook?;
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(doc, &mut art, false);
members_as_drawn(doc, &mut art);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
// The art's own fills and strokes follow the effects as its members do.
if carry.moves(&art.appearance) {
carry.apply(&mut art.appearance);
}
(art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate);
art.appearance.effects = n.appearance.effects.clone();
without_geometry_effects(&mut art);
Some(art)
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, doc, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects. The swatches and patterns a live-effect plug-in
/// that paints its pieces names are looked up in `doc`.
fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let (g, carry) =
crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext { doc, ..crate::GeomContext::of(n) });
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
// So do the raster effects of its fills and strokes, by `n`'s Transform effects
// ([`crate::follow_items`]) that moved the outline.
let mut owner = std::borrow::Cow::Borrowed(n.appearance.effects.as_slice());
if carry.skipped() {
carry.hide_skipped(owner.to_mut());
if m.appearance.effects == n.appearance.effects {
let mut ap = (*m.appearance).clone();
ap.effects = owner.to_vec();
m.appearance = ap.into();
}
}
crate::raster::follow_items_of(&mut m, &owner);
}
without_geometry_effects(&mut m);
m
}
fn without_geometry_effects(n: &mut Node) {
if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
let mut ap = (*n.appearance).clone();
ap.effects = crate::raster::without_geometry(&ap.effects);
n.appearance = ap.into();
}
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => 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))
}
_ => None,
}
}
/// The operation that combines a compound shape's member in `mode` with the area below it.
fn shape_op(mode: ShapeMode) -> po::BoolOp {
match mode {
ShapeMode::Add => po::BoolOp::Union,
ShapeMode::Subtract => po::BoolOp::Difference,
ShapeMode::Intersect => po::BoolOp::Intersect,
ShapeMode::Exclude => po::BoolOp::Xor,
}
}
/// The area compound shape `n` draws: each visible member's shape (a group's leaves together, type
/// as its outlines through `hook`, open paths closed) combined with the area of the members below
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
if !n.is_compound_shape() {
return None;
}
let mut all: Vec<Node> = vec![];
// Each member that has a shape: its operation and how many of the leaves are its.
let mut members: Vec<(po::BoolOp, usize)> = vec![];
for c in n.children()? {
let before = all.len();
leaves(doc, c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
}
if nodes.spent() || !within_budget(&all) {
return None;
}
if all.is_empty() {
return Some(PathData::default());
}
let parts: Vec<(PathData, FillRule)> = all.iter().map(|l| geometry(l).unwrap_or_default()).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
Some(po::boolean_n(&refs, |inside| {
let (mut start, mut area) = (0, false);
for (k, (op, count)) in members.iter().enumerate() {
// `inside` is ascending: the first leaf at or after `start` tells whether one of this member's is in it.
let here = inside.get(inside.partition_point(|&i| i < start)).is_some_and(|&i| i < start + count);
start += count;
area = if k == 0 { here } else { op.apply(area, here) };
}
area
}))
}
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
/// opacity and blend mode: how it takes part in a Pathfinder operation.
pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
compound_shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(doc, n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
}
/// 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(), ..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))], ..Default::default() };
}
let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity;
n.blend = src.blend;
n
}
/// Most segments a live Pathfinder effect takes in, as for the Shape Builder.
pub const PATHFINDER_MAX_SEGMENTS: usize = po::SHAPE_BUILDER_MAX_SEGMENTS;
/// Most pairs of input paths whose bounds overlap a live Pathfinder effect takes in. Crossings,
/// and so the pieces of the result, grow with these pairs (n bars crossing n make n² crossings)
/// and the effect runs on every draw: past this the group draws its art unprocessed (#3591).
pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384;
/// Fenwick tree of counts over `n` slots, for counting the active boxes of [`within_budget`].
struct Counts(Vec<isize>);
// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version).
#[allow(unknown_lints, clippy::manual_isolate_lowest_one)]
impl Counts {
fn add(&mut self, slot: usize, d: isize) {
let mut i = slot + 1;
while let Some(c) = self.0.get_mut(i) {
*c += d;
i += i & i.wrapping_neg();
}
}
/// Sum of the slots before `end`.
fn before(&self, end: usize) -> isize {
let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0);
while i > 0 {
s += self.0.get(i).copied().unwrap_or(0);
i -= i & i.wrapping_neg();
}
s
}
}
/// Can a live Pathfinder evaluate `leaves` within [`PATHFINDER_MAX_SEGMENTS`] and
/// [`PATHFINDER_MAX_OVERLAPS`]? The overlapping pairs of bounds are counted exactly by a sweep
/// in x that keeps the active boxes in two Fenwick trees over their y extents, so the check costs
/// O(n log n) however the boxes are laid out.
fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
let mut segments = 0usize;
let mut boxes: Vec<Rect> = vec![];
for l in leaves {
let Some((path, _)) = geometry(l) else { continue };
segments += path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>();
if segments > PATHFINDER_MAX_SEGMENTS {
return false;
}
boxes.extend(path.bounds());
}
let mut ys: Vec<f64> = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| a.total_cmp(b).is_eq());
// Slots of the y values below `y`, and of those at or below it.
let below = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_lt());
let upto = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_le());
// (x, is end, box); starts sort before ends at the same x since touching boxes overlap.
let mut events: Vec<(f64, bool, usize)> = boxes.iter().enumerate().flat_map(|(i, b)| [(b.x0, false, i), (b.x1, true, i)]).collect();
events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
// Active boxes counted by the slot of their y0 and of their y1.
let mut by_y0 = Counts(vec![0; ys.len() + 1]);
let mut by_y1 = Counts(vec![0; ys.len() + 1]);
let (mut active, mut overlaps) = (0isize, 0usize);
for (_, end, i) in events {
let Some(b) = boxes.get(i) else { continue };
if end {
by_y0.add(below(b.y0), -1);
by_y1.add(below(b.y1), -1);
active -= 1;
continue;
}
// Active boxes not entirely below b (y1 < b.y0) nor entirely above it (y0 > b.y1).
let above = active - by_y0.before(upto(b.y1));
let under = by_y1.before(below(b.y0));
overlaps += usize::try_from(active - above - under).unwrap_or(0);
if overlaps > PATHFINDER_MAX_OVERLAPS {
return false;
}
by_y0.add(below(b.y0), 1);
by_y1.add(below(b.y1), 1);
active += 1;
}
true
}
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
/// each effect) it returns the children as they are. The swatches and patterns that live-effect
/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]).
pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
}
let source: &[Arc<Node>] = group.children()?;
// A layer's ruler guides take no part in the operation: they stay above its result.
let art = Node::guide_run_start(source);
if art < source.len() {
let mut without = group.clone();
if let Some(ch) = without.children_mut() {
ch.truncate(art);
}
let mut out = pathfinder_children(doc, &without, hook)?;
out.extend(source.iter().skip(art).cloned());
return Some(out);
}
let mut stack: Vec<Arc<Node>> = if group.is_compound_shape() {
// The group's own appearance paints the area ([`evaluate_container`]), so the art has none.
match compound_shape_area(doc, group, hook) {
Some(area) if area.is_empty() => vec![],
Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))],
None => return Some(source.to_vec()),
}
} else {
source.to_vec()
};
for e in group.appearance.effects.iter().filter(|e| e.visible) {
if e.id == crate::trap::TRAP {
let (mut lv, nodes) = (vec![], SymbolNodes::default());
for c in &stack {
leaves(doc, c, hook, &nodes, &mut lv);
}
if nodes.spent() || !within_budget(&lv) {
return Some(source.to_vec());
}
let (ids, srcs): (Vec<NodeId>, Vec<crate::trap::TrapSource>) = lv
.iter()
.filter_map(|l| {
let (path, rule) = geometry(l)?;
Some((l.id, crate::trap::TrapSource { path, rule, fill: l.appearance.fill_paint() }))
})
.unzip();
let made = crate::trap::traps(&srcs, &crate::trap::TrapOptions::from_params(&e.params));
stack.extend(made.into_iter().map(|p| Arc::new(crate::trap::trap_node(ids.get(p.from).copied().unwrap_or(NodeId(0)), p))));
continue;
}
let Some(op) = op_of(&e.id) else { continue };
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let nodes = SymbolNodes::default();
let all: Vec<Vec<Node>> = stack
.iter()
.map(|c| {
let mut lv = vec![];
leaves(doc, c, hook, &nodes, &mut lv);
lv
})
.collect();
if nodes.spent() || !within_budget(all.iter().flatten()) {
return Some(source.to_vec());
}
// (style source, shape) per stack entry; keys index `sources`.
let mut sources: Vec<Node> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
for mut lv in all {
if shape_mode && lv.len() > 1 {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let key = sources.len() as u64;
if let Some(top) = lv.pop() {
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, key));
sources.push(top);
}
} else {
for l in lv {
if let Some((p, r)) = geometry(&l) {
shapes.push(po::Shape::new(p, r, sources.len() as u64));
sources.push(l);
}
}
}
}
shapes.retain(|s| !s.path.is_empty() && s.path.subpaths.iter().all(|sp| sp.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())));
if shapes.is_empty() {
return Some(vec![]);
}
stack = if op == PathfinderOp::SoftMix {
let rate = mixing_rate(&e.params);
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(soft_mix_face(r.path, &r.sources, rate, &shapes, &sources)))
.collect()
} else if op == PathfinderOp::HardMix {
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(hard_mix_face(r.path, &r.sources, &shapes, &sources)))
.collect()
} else {
po::pathfinder(op, &shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
.collect()
};
}
Some(stack)
}
fn mixing_rate(params: &Value) -> f64 {
params.get("mixingRate").and_then(|v| v.as_f64()).filter(|r| r.is_finite() && *r > 0.0 && *r <= 100.0).map(|r| r / 100.0).unwrap_or(0.5)
}
fn fill_cmyk(src: &Node) -> Option<[f32; 4]> {
let paint = src.appearance.fill_paint();
let color = paint.color()?;
let tinted = match paint {
Paint::Solid { tint, .. } => color.tinted(tint),
_ => color,
};
Some(tinted.to_cmyk())
}
/// One face of a Soft Mix: CMYK blended bottom to top at `rate`; no stroke.
fn soft_mix_face(path: PathData, covering: &[usize], rate: f64, shapes: &[po::Shape], sources: &[Node]) -> Node {
let rate = rate as f32;
let mut cmyk: Option<[f32; 4]> = None;
for &si in covering {
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
cmyk = Some(match cmyk {
None => ch,
Some(acc) => {
let t = rate;
let u = 1.0 - t;
[acc[0] * u + ch[0] * t, acc[1] * u + ch[1] * t, acc[2] * u + ch[2] * t, acc[3] * u + ch[3] * t]
}
});
}
let fill = cmyk.map(|c| Paint::solid(Color::cmyk(c[0], c[1], c[2], c[3]))).unwrap_or(Paint::None);
soft_mix_styled(path, covering, shapes, sources, fill)
}
/// One face of a Hard Mix: the max of each CMYK channel over every covering shape's fill; no stroke.
fn hard_mix_face(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node]) -> Node {
let mut cmyk = [0.0f32; 4];
let mut painted = false;
for &si in covering {
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
for (out, v) in cmyk.iter_mut().zip(ch) {
*out = out.max(v);
}
painted = true;
}
let fill = if painted { Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])) } else { Paint::None };
soft_mix_styled(path, covering, shapes, sources, fill)
}
fn soft_mix_styled(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node], fill: Paint) -> Node {
let top = covering.last().and_then(|&si| sources.get(shapes[si].key as usize));
let mut n = Node::path(top.map_or(NodeId(0), |s| s.id), path, Appearance::basic(fill, Paint::None, 0.0));
if let Some(src) = top {
n.opacity = src.opacity;
n.blend = src.blend;
}
n
}
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Does `item` paint anything?
pub 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; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, 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::Group { .. } if n.is_compound_shape() => {
let hook = |c: &Node| crate::outline_object(doc, c);
match compound_shape_area(doc, n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
}
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(doc, n, out),
_ if vectorcraft_doc::live::is_live(n) => members(doc, &crate::reshape::outlined_members(doc, n), out),
_ => {}
}
}
fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
push(doc, c, out);
}
}
let mut out = vec![];
members(doc, n, &mut out);
out
}
/// A path node of `id` on `path` (filled under `rule`) painted by `ap`.
pub(crate) 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)
}
/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide.
fn sublayer_guides(n: &Node) -> bool {
n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))))
}
/// Move the ruler guides of the sublayers among the members of `n`, and among its groups'
/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside
/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied.
fn take_sublayer_guides(n: &mut Node, out: &mut Vec<Arc<Node>>, hidden: bool, locked: bool) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) {
let c = Arc::make_mut(c);
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
if c.is_layer()
&& let Some(lc) = c.children_mut()
{
let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide());
*lc = art;
out.extend(taken.into_iter().map(|mut g| {
if hidden || locked {
let g = Arc::make_mut(&mut g);
g.visible &= !hidden;
g.locked |= locked;
}
g
}));
}
take_sublayer_guides(c, out, hidden, locked);
}
}
/// `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) after
/// the members' own geometry and Pathfinder effects ([`members_as_drawn`]), 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. Live
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and
/// a Pathfinder operation or compound shape takes the symbol instances among its members as their
/// symbols' art from `doc`.
pub fn evaluate_container(doc: Option<&Document>, 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();
// A layer's ruler guides take no part: they go back above the evaluated art at the end.
let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default();
// So do its sublayers' guides, which would otherwise go with the art the effects replace or
// copy: they stay on this layer, hidden and locked as their sublayers were.
if m.is_layer() && (pathfinder || !fx.is_empty()) {
take_sublayer_guides(&mut m, &mut guides, false, false);
}
if pathfinder {
let hook = |c: &Node| crate::outline_object(doc, c);
let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
// The area is art now: drawn again, it is no compound shape of its own.
m.extras_mut().compound_shape = false;
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(doc, &m);
members_as_drawn(doc, &mut m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes paint the members as a whole: their gradients follow
// the effects as the members' do.
if paint && carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
}
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
if paint {
let shapes = member_shapes(doc, &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;
if let Some(ch) = m.children_mut() {
ch.extend(guides);
}
Some(m)
}
/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every
/// depth of its groups and layers, by the art it draws ([`evaluate_container`],
/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members
/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the
/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with
/// everything in it. Calls nested in one another, through the
/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members
/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at
/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are.
pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) {
let Some(_frame) = DrawnFrame::enter() else { return };
let clip = usize::from(n.clips());
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut().skip(clip).filter(|c| c.visible) {
if !DrawnFrame::take_member() {
return;
}
// A group or layer with a 3D effect stays whole: the geometry effects listed after its 3D
// effect reshape its 3D art.
if is_container(c) && (crate::has_solid(c) || crate::has_revolve(c)) {
continue;
}
let art = if !is_container(c) {
crate::reshape::own_effects_art(doc, c)
} else if has_pathfinder(c) || !crate::reshape::geometry_effects(c).is_empty() {
evaluate_container(doc, c)
} else {
None
};
match art {
Some(a) => *c = Arc::new(a),
None if is_container(c) => members_as_drawn(doc, Arc::make_mut(c)),
None => {}
}
}
}
thread_local! {
/// How many [`members_as_drawn`] calls are running, one inside another, and how many members
/// they have visited since the outermost one started.
static DRAWN: std::cell::Cell<(usize, u64)> = const { std::cell::Cell::new((0, 0)) };
}
/// One running [`members_as_drawn`] call, counted in [`DRAWN`] until it is dropped.
struct DrawnFrame;
impl DrawnFrame {
/// Count one more call; `None` when [`vectorcraft_doc::depth::MAX_DEPTH`] are running. The
/// outermost call starts the count of members again.
fn enter() -> Option<Self> {
let (frames, members) = DRAWN.with(|d| d.get());
if frames >= vectorcraft_doc::depth::MAX_DEPTH {
return None;
}
DRAWN.with(|d| d.set((frames + 1, if frames == 0 { 0 } else { members })));
Some(DrawnFrame)
}
/// Count one more member visited; `false` once [`crate::SYMBOL_ART_MAX_NODES`] have been.
fn take_member() -> bool {
let (frames, members) = DRAWN.with(|d| d.get());
if members >= crate::SYMBOL_ART_MAX_NODES {
return false;
}
DRAWN.with(|d| d.set((frames, members + 1)));
true
}
}
impl Drop for DrawnFrame {
fn drop(&mut self) {
DRAWN.with(|d| {
let (frames, members) = d.get();
d.set((frames.saturating_sub(1), members));
});
}
}
#[cfg(test)]
mod tests;