//! The art a 3D effect builds its solids from when its object, or a member of its group, shows art //! it does not hold as paths: a symbol instance its symbol's art placed by the instance, a blend its //! key objects and steps, a clipping group (or a layer with a clipping mask) its art inside the //! clipping path. Each becomes a plain group of that art, keeping its transparency, so the solids //! build from what the object shows. Images build no solid: they are traced first. use std::borrow::Cow; use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops as po; use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints}; use crate::{GeomContext, is_geometry}; /// The deepest nesting of symbol instances, blends and clipping groups resolved for one 3D effect. const MAX_DEPTH: usize = 64; /// The most objects resolving one 3D effect's art may visit or make: every object listed (hidden /// ones and guides included), every symbol instance, blend and clipping group resolved, every blend /// step made and every object cut to a clipping path. A symbol that holds instances of another /// repeats that symbol's art at every level, so art with few visible objects can still take this /// many steps. pub(super) const MAX_WORK: usize = 1 << 18; /// The error for an image a 3D effect is asked to build from. pub(crate) fn image_refusal(label: &str) -> String { format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.") } /// Is `n` a symbol instance, a blend or a clipping group or layer? pub(crate) fn resolves(n: &Node) -> bool { matches!(n.kind, NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. }) || n.clips() } /// Is `n`, or a visible member of `n` at any depth of its groups and layers, one [`resolves`] takes? fn needs(n: &Node) -> bool { let mut stack = vec![n]; while let Some(n) = stack.pop() { if resolves(n) { return true; } if is_container(n) { stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| &**c)); } } false } /// `n` as a 3D effect builds from it: unchanged when neither it nor a visible member of its groups /// is a symbol instance, a blend or a clipping group, otherwise a group with `n`'s id, appearance /// and transparency holding that art (a symbol instance's symbol art from `doc`). An error names /// what can't be resolved: an image, a missing symbol, art nested or numbering past the limits, or /// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut /// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result, String> { let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); resolve_for(doc, n, label, every_fill) } /// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every /// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the /// effect builds from the top fill, and the fills cut go together in the top fill's place. pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result, String> { if !needs(n) { return Ok(Cow::Borrowed(n)); } let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None }; let children = r.content(n, 0); #[cfg(test)] WORK.with(|w| w.set(r.work)); let mut out = n.clone(); out.kind = NodeKind::Group { children: children?, clip: false }; Ok(Cow::Owned(out)) } #[cfg(test)] thread_local! { /// The objects the last [`resolve`] on this thread visited or made ([`Resolver::work`]). pub(super) static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } struct Resolver<'a> { doc: Option<&'a Document>, label: &'a str, /// Does each fill a clipping path cuts become a piece of its own ([`resolve_for`])? every_fill: bool, /// The objects other than groups and layers kept so far, counted against [`MAX_MEMBERS`]. leaves: usize, /// The objects visited or made so far, counted against [`MAX_WORK`]. work: usize, /// Inside clipping groups, the box of what their clipping paths show together. shown: Option, } impl Resolver<'_> { /// Count `steps` more objects visited or made, or an error past [`MAX_WORK`]. fn spend(&mut self, steps: usize) -> Result<(), String> { self.work = self.work.saturating_add(steps); if self.work > MAX_WORK { return Err(self.too_much_work()); } Ok(()) } fn too_much_work(&self) -> String { format!("{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects", self.label) } /// `f` run on the art of `c`. The effects of an object or group can move its art into a clip, /// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]). fn inside(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result) -> Result { let outer = self.shown; if has_effects(c) { self.shown = None; } let out = f(self); self.shown = outer; out } /// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one, /// shows, as the members of a plain group. fn content(&mut self, n: &Node, depth: usize) -> Result>, String> { let label = self.label; if depth > MAX_DEPTH { return Err(self.too_deep()); } self.spend(1)?; match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let missing = || format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available"); let doc = self.doc.ok_or_else(missing)?; let sym = doc.symbols.iter().find(|s| s.name == *symbol).ok_or_else(missing)?; // Counted before the art is placed, so a symbol past the limit is never copied: // first its own objects, which the instance's overrides keep, then the most // objects placing makes, with those overrides and the symbol's 9-slice guides. if self.work.saturating_add(sym.art.count_with_masks()) > MAX_WORK { return Err(self.too_much_work()); } let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); if (self.work as u64).saturating_add(most) > MAX_WORK as u64 { return Err(self.too_much_work()); } let placed = doc.place_symbol_art(symbol, &art, *xf).ok_or_else(missing)?; Ok(self.list(&[Arc::new(placed)], depth + 1)?.0) } NodeKind::Blend { children, spec } => { // Counted before the steps are made, so a blend past the limit is never expanded. let made = vectorcraft_doc::blend::blend_steps(children, spec).into_iter().fold(children.len(), usize::saturating_add); if self.leaves.saturating_add(made) > MAX_MEMBERS { return Err(self.too_many()); } self.spend(made)?; let steps: Vec> = crate::expand_live(self.doc, n).into_iter().map(Arc::new).collect(); Ok(self.list(&steps, depth + 1)?.0) } _ if n.clips() => { let Some((first, rest)) = kids(n).split_first() else { return Ok(vec![]) }; if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) { return Err(self.too_deep()); } let clip = clip_area(first); // An image wholly outside every clip around it shows nothing and is left out. let outer = self.shown; let here = clip.0.bounds().unwrap_or(Rect::ZERO); self.shown = Some(outer.map_or(here, |o| o.intersect(here))); let listed = self.list(rest, depth + 1); self.shown = outer; let (inner, _) = listed?; let mut out = vec![]; for c in &inner { if let Some(c) = self.clipped(c, &clip, depth + 1)? { out.push(Arc::new(c)); } } Ok(out) } _ => Ok(self.list(kids(n), depth + 1)?.0), } } /// The visible members of `children` as plain art: images refused, guides left out, symbol /// instances, blends and clipping groups resolved into groups with their transparency and /// geometry effects, groups holding them rebuilt. Also whether any member changed: a group /// whose members did not is kept as it is. Each object is visited once. fn list(&mut self, children: &[Arc], depth: usize) -> Result<(Vec>, bool), String> { let mut out = Vec::with_capacity(children.len()); let mut changed = false; for c in children { self.spend(1)?; if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } if matches!(c.kind, NodeKind::Image(_)) { // Only an image with no effects or fills and strokes of its own is measured by its // frame; any other one may draw inside the clip. let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible()); let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0)); if hidden { changed = true; continue; } return Err(image_refusal(self.label)); } if resolves(c) { let kids = self.inside(c, |r| r.content(c, depth))?; out.push(Arc::new(with_effects(wrap(c, kids), c))); changed = true; continue; } if is_container(c) { // At the depth limit a group is kept, and not walked further, only when it holds no // group with members and nothing in it needs resolving. if depth >= MAX_DEPTH { if nested_past(c, 1) || needs(c) { return Err(self.too_deep()); } out.push(c.clone()); continue; } let (kids, inner) = self.inside(c, |r| r.list(kids(c), depth + 1))?; if inner { out.push(Arc::new(with_effects(wrap(c, kids), c))); changed = true; } else { out.push(c.clone()); } continue; } self.leaves += 1; if self.leaves > MAX_MEMBERS { return Err(self.too_many()); } out.push(c.clone()); } Ok((out, changed)) } fn too_complex(&self) -> String { format!("{}: the clipping path is too complex to cut the art with", self.label) } fn too_many(&self) -> String { format!("{} takes at most {MAX_MEMBERS} objects", self.label) } fn too_deep(&self) -> String { format!("{} can't build solids from art nested more than {MAX_DEPTH} levels deep", self.label) } /// `n`, plain art inside a clipping group, cut to `clip`: each shape's fills as its area inside /// the clip and each stroke as the part of its outline inside it, filled in the stroke's paint, /// after the geometry effects of the shape and of the groups it is in; hidden objects and guides /// left out. `None` when nothing of it is inside. Objects that build no solid stay as they are. fn clipped(&mut self, n: &Node, clip: &(PathData, FillRule), depth: usize) -> Result, String> { if depth > MAX_DEPTH { return Err(self.too_deep()); } self.spend(1)?; if is_container(n) { let mut kids = vec![]; if n.appearance.effects.iter().any(|e| e.visible && is_geometry(&e.id)) { // The group's geometry effects reshape its members together before the clip cuts // them, as they are drawn: each member becomes its reshaped outline. if nested_past(n, MAX_DEPTH.saturating_sub(depth)) { return Err(self.too_deep()); } let mut shapes = members(n, self.label)?; reshape_members(n, &n.appearance.effects, &mut shapes); let mut cut = vec![]; for s in shapes { if let Some(c) = self.clipped(&shape(n, s.path, s.rule, s.items), clip, depth + 1)? { cut.push((s.scopes, c)); } } kids = nest(n, cut, 0); } else { for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } if let Some(c) = self.clipped(c, clip, depth + 1)? { kids.push(Arc::new(c)); } } } return Ok((!kids.is_empty()).then(|| wrap(n, kids))); } let Some((path, rule)) = profile(n) else { return Ok(Some(n.clone())) }; let items = solid_paints(n); // The fills' piece goes where the top fill is, the one Extrude & Bevel and Inflate build // their solid from, so the strokes above and below it keep their places. Rotate turns // every fill, so each fill is a piece in its own place. let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); let fill_pieces = if self.every_fill { items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() } else { usize::from(top_fill.is_some()) }; // Each cut makes a piece, counted before it is made: the shape was counted once already. let made = fill_pieces + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); self.spend(made)?; self.leaves = self.leaves.saturating_add(made.saturating_sub(1)); if self.leaves > MAX_MEMBERS { return Err(self.too_many()); } let path = geometry(&path, &n.appearance.effects, &GeomContext::of(n)); let cut = |area: &PathData, rule: FillRule| -> Result, String> { let inside = po::try_boolean(area, rule, &clip.0, clip.1, po::BoolOp::Intersect, po::DEFAULT_PRECISION).map_err(|_| self.too_complex())?; Ok(inside.bounds().is_some().then_some(inside)) }; let fills: Vec = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect(); // Every fill paints the same area: it is cut once. let mut fill_area: Option> = None; let mut pieces = vec![]; for (k, item) in items.iter().enumerate() { match item { AppearanceItem::Fill(_) if self.every_fill || Some(k) == top_fill => { let area = match &fill_area { Some(area) => area.clone(), None => fill_area.insert(cut(&path, rule)?).clone(), }; if let Some(area) = area { let painted = if self.every_fill { vec![item.clone()] } else { fills.clone() }; pieces.push(Arc::new(shape(n, area, FillRule::NonZero, painted))); } } AppearanceItem::Fill(_) => {} AppearanceItem::Stroke(s) => { let (outline, outline_rule) = region(&path, rule, item); if let Some(area) = cut(&outline, outline_rule)? { let mut fill = FillLayer::new(s.paint.clone()); fill.opacity = s.opacity; fill.blend = s.blend; pieces.push(Arc::new(shape(n, area, FillRule::NonZero, vec![AppearanceItem::Fill(fill)]))); } } } } Ok((!pieces.is_empty()).then(|| wrap(n, pieces))) } } /// The area clipping path `first` keeps, as the renderer clips by it ([`crate::clip_outline`]): a /// nested clipping group's own clipping path, the union of a group's members. Empty when it keeps /// nothing. fn clip_area(first: &Node) -> (PathData, FillRule) { crate::clip_outline(first).map_or_else(|| (PathData::default(), FillRule::NonZero), |(bp, rule)| (PathData::from_bezpath(&bp), rule)) } /// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with /// those groups' opacity, blending mode and opacity mask: consecutive pieces sharing the scope at /// `level` go into one group, so its transparency applies to them together. `level` stays below the /// nesting the pieces came from ([`nested_past`]). fn nest(n: &Node, pieces: Vec<(Vec, Node)>, level: usize) -> Vec> { let mut out = vec![]; let mut run: Vec<(Vec, Node)> = vec![]; let mut key: Option = None; let close = |run: &mut Vec<(Vec, Node)>, scope: Option, out: &mut Vec>| { let Some(scope) = scope else { return }; out.push(Arc::new(scope.group(n.id, nest(n, std::mem::take(run), level + 1)))); }; for (scopes, piece) in pieces { let here = scopes.get(level).cloned(); if here.as_ref().map(|s| s.key) != key.as_ref().map(|s| s.key) { close(&mut run, key.take(), &mut out); key = here.clone(); } match here { Some(_) => run.push((scopes, piece)), None => out.push(Arc::new(piece)), } } close(&mut run, key, &mut out); out } /// Does `n` carry a visible effect? fn has_effects(n: &Node) -> bool { n.appearance.effects.iter().any(|e| e.visible) } /// Does the group `n` hold shown objects nested more than `levels` deep in groups and layers? /// Only the members [`members`] walks are followed: hidden members are skipped, and the paths of /// a compound path are one shape. Walked without recursion. fn nested_past(n: &Node, levels: usize) -> bool { let mut stack = vec![(n, 0usize)]; while let Some((n, level)) = stack.pop() { if level > levels { return true; } if level == 0 || is_container(n) { stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1))); } } false } /// Does the clipping path `n` reach objects nested more than `levels` deep along the members that /// [`Node::clip_shapes`] visits: the first member of a clipping group, the shown members of other /// groups and layers (not a group's shaper), and every member of a live object. Walked without /// recursion. fn clip_nested_past(n: &Node, levels: usize) -> bool { let mut stack = vec![(n, 0usize)]; while let Some((n, level)) = stack.pop() { if level > levels { return true; } match &n.kind { NodeKind::Group { children, clip: true } => stack.extend(children.first().map(|c| (&**c, level + 1))), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { let skip = usize::from(n.extras().shaper.is_some()); stack.extend(children.iter().skip(skip).filter(|c| c.visible).map(|c| (&**c, level + 1))); } NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. } => {} _ => stack.extend(kids(n).iter().map(|c| (&**c, level + 1))), } } false } /// The members of `n`, none for an object without members. fn kids(n: &Node) -> &[Arc] { n.children().map_or(&[], Vec::as_slice) } /// A path of `n`'s id on `path`, painted by `items` under `rule`. fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec) -> Node { let mut piece = Node::path(n.id, path, Appearance { items, ..Default::default() }); if let NodeKind::Path { rule: r, .. } = &mut piece.kind { *r = rule; } piece } /// A plain group of `children` with `n`'s id, visibility, opacity, blending mode and opacity mask. fn wrap(n: &Node, children: Vec>) -> Node { let mut g = Node::group(n.id, children); g.name = n.name.clone(); g.opacity = n.opacity; g.blend = n.blend; if let Some(mask) = &n.extras().mask { g.extras_mut().mask = Some(mask.clone()); } g } /// `g`, the group `n` was resolved into, with `n`'s appearance: a 3D effect's group reshapes its /// members with its geometry effects ([`members`]), which read its stroke as they do when `n` is /// drawn. A group's own fills and strokes build no solid. fn with_effects(mut g: Node, n: &Node) -> Node { g.appearance = n.appearance.clone(); g }