Files
vectorcraft/crates/effects/src/solids/source.rs
T
BFlat b08e9df95c Merge pull request #6478 from dotCooCoo/fix/3d-member-opacity-masks
3D: an opacity mask on a group member masks its solids
2026-11-11 07:44:06 +07:00

456 lines
21 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, 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<Cow<'a, Node>, 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<Cow<'a, Node>, 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<usize> = 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<Rect>,
}
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<T>(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result<T, String>) -> Result<T, String> {
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<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, 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<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![]) };
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<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(_)) {
// 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<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);
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<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 fills: Vec<AppearanceItem> = 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<Option<PathData>> = 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<Scope>, Node)>, level: usize) -> Vec<Arc<Node>> {
let mut out = vec![];
let mut run: Vec<(Vec<Scope>, Node)> = vec![];
let mut key: Option<Scope> = None;
let close = |run: &mut Vec<(Vec<Scope>, Node)>, scope: Option<Scope>, out: &mut Vec<Arc<Node>>| {
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<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, blending mode and opacity mask.
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;
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
}