Files
vectorcraft/crates/effects/src/solids/source.rs
T
dotCooCoo bad4b986f5 3D: resolving art for a 3D effect has a work limit and keeps geometry effects
- Resolving the symbol instances, blends and clipping groups a 3D effect
  builds from counts every object it visits or makes and stops past
  262,144 with an error. Nested symbol instances that end in hidden art
  are refused instead of being expanded at every level. Each object is
  visited once; a group is no longer walked again for each level above it.
- A symbol instance, blend or clipping group inside a 3D group keeps its
  appearance, so its geometry effects reshape the art it shows. A nested
  group's geometry effects now reshape its members inside a 3D group too,
  and inside a clipping group they apply before the clip cuts the art.
- Hidden members and guides of a group inside a clipping group build no
  solid.
- Tests cover an empty clipping group, a blend set to no steps, an
  instance of an empty symbol, a symbol holding a clipping group, the
  paint-copy budget across resolved art, and art past the limits drawing
  as it does without the effect.
2026-11-10 00:29:58 -08:00

324 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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};
use vectorcraft_pathops as po;
use super::{MAX_MEMBERS, 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.
pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> {
if !needs(n) {
return Ok(Cow::Borrowed(n));
}
let mut r = Resolver { doc, label, leaves: 0, work: 0 };
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<usize> = const { std::cell::Cell::new(0) };
}
struct Resolver<'a> {
doc: Option<&'a Document>,
label: &'a str,
/// 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,
}
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(format!(
"{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects",
self.label
));
}
Ok(())
}
/// 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<Vec<Arc<Node>>, String> {
let label = self.label;
if depth > MAX_DEPTH {
return Err(self.too_deep());
}
self.spend(1)?;
match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
let art = crate::outline_object(self.doc, n).ok_or_else(|| {
format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available")
})?;
Ok(self.list(kids(&art), 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<Arc<Node>> = 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![]) };
let clip = clip_area(first);
let (inner, _) = self.list(rest, depth + 1)?;
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<Node>], depth: usize) -> Result<(Vec<Arc<Node>>, 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(_)) {
return Err(image_refusal(self.label));
}
if resolves(c) {
let kids = self.content(c, depth)?;
out.push(Arc::new(with_effects(wrap(c, kids), c)));
changed = true;
continue;
}
if is_container(c) {
// Past the depth limit a group is kept, and not walked further, only when nothing
// in it needs resolving.
if depth >= MAX_DEPTH {
if needs(c) {
return Err(self.too_deep());
}
out.push(c.clone());
continue;
}
let (kids, inner) = self.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<Option<Node>, 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);
for s in shapes {
let mut piece = shape(n, s.path, s.rule, s.items);
piece.opacity = s.opacity;
if let Some(c) = self.clipped(&piece, clip, depth + 1)? {
kids.push(Arc::new(c));
}
}
} 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 path = geometry(&path, &n.appearance.effects, &GeomContext::of(n));
let cut = |area: &PathData, rule: FillRule| -> Result<Option<PathData>, 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 items = solid_paints(n);
let fills: Vec<AppearanceItem> = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect();
// 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.
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let mut pieces = vec![];
for (k, item) in items.iter().enumerate() {
match item {
AppearanceItem::Fill(_) if Some(k) == top_fill => {
if let Some(area) = cut(&path, rule)? {
pieces.push(Arc::new(shape(n, area, FillRule::NonZero, fills.clone())));
}
}
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))
}
/// Does `n` hold visible groups or layers nested more than `levels` deep? 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 is_container(n) {
stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1)));
}
}
false
}
/// The members of `n`, none for an object without members.
fn kids(n: &Node) -> &[Arc<Node>] {
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<AppearanceItem>) -> 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 and blending mode.
fn wrap(n: &Node, children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(n.id, children);
g.name = n.name.clone();
g.opacity = n.opacity;
g.blend = n.blend;
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
}