diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index f70947176..b31f7ca71 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -1165,6 +1165,38 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); } + #[test] + fn the_art_of_symbol_instances_and_blends_shares_the_budget_of_paint_copies() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{BlendSpacing, BlendSpec, Symbol}; + let plain = member(1, 0.0); + let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let painted = |id: u64, x: f64| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut node = (*member(id, x)).clone(); + node.appearance = Appearance::basic(paint, Paint::None, 0.0).into(); + Arc::new(node) + }; + let mut d = Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: false, name: "P".into(), art: painted(0, 0.0) }); + let instance = |id: u64| Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: "P".into(), xf: Affine::IDENTITY })); + // One instance's art fits; two in the same place, each of whose surfaces fits alone, pass + // the budget together, as two paths would. + assert_eq!(validate_revolve_in(Some(&d), &group_with(json!({}), vec![instance(1)]), &json!({})), Ok(())); + let two = group_with(json!({}), vec![instance(1), instance(2)]); + let refused = validate_revolve_in(Some(&d), &two, &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(revolve_art_in(Some(&d), &two), None, "past the budget the source draws"); + // A blend's keys and step count together too. + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() }); + let blend = Arc::new(Node::new(NodeId(3), NodeKind::Blend { children: vec![painted(4, 0.0), painted(5, 0.0)], spec })); + let refused = validate_revolve_in(Some(&d), &group_with(json!({}), vec![blend]), &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + } + #[test] fn the_fills_of_one_object_share_the_budget_of_paint_copies() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7c463dccf..06461f8de 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -280,7 +280,8 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, /// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends /// and clipping groups), at any depth of its groups, each through its own geometry -/// effects and painted with its own fills and strokes. Like the effect on a group in the +/// effects, then those of each group it is in (on that group's members together), and painted +/// with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. pub(crate) fn members(n: &Node, label: &str) -> Result, String> { @@ -302,7 +303,12 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); } if is_container(c) { + let first = w.out.len(); walk(c, opacity, &scopes, w)?; + // The group's own geometry effects reshape its members together, as it is drawn. + if let Some(shapes) = w.out.get_mut(first..) { + reshape_members(c, &c.appearance.effects, shapes); + } continue; } let Some((path, rule)) = profile(c) else { diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index efc5b6c29..bdc6e1085 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -11,12 +11,19 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, Nod use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops as po; -use super::{MAX_MEMBERS, geometry, is_container, profile, region, solid_paints}; -use crate::GeomContext; +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.") @@ -50,33 +57,57 @@ pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> R if !needs(n) { return Ok(Cow::Borrowed(n)); } - let mut r = Resolver { doc, label, leaves: 0 }; - let children = r.content(n, 0)?; + 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, clip: false }; + 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, /// 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<'_> { - /// The art `n` shows, as the members of a plain group. + /// 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>, 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") })?; - self.list(kids(&art), depth + 1) + 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. @@ -84,13 +115,14 @@ impl Resolver<'_> { 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(); - self.list(&steps, depth + 1) + 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 (inner, _) = self.list(rest, depth + 1)?; let mut out = vec![]; for c in &inner { if let Some(c) = self.clipped(c, &clip, depth + 1)? { @@ -99,36 +131,57 @@ impl Resolver<'_> { } Ok(out) } - _ => self.list(kids(n), depth + 1), + _ => 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, groups - /// holding them rebuilt. - fn list(&mut self, children: &[Arc], depth: usize) -> Result>, String> { + /// 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(_)) { return Err(image_refusal(self.label)); } - if needs(c) { + if resolves(c) { let kids = self.content(c, depth)?; - out.push(Arc::new(wrap(c, kids))); + out.push(Arc::new(with_effects(wrap(c, kids), c))); + changed = true; continue; } - if !is_container(c) { - self.leaves += 1; - if self.leaves > MAX_MEMBERS { - return Err(self.too_many()); + 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) + Ok((out, changed)) } fn too_complex(&self) -> String { @@ -144,17 +197,39 @@ impl Resolver<'_> { } /// `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; - /// `None` when nothing of it is inside. Objects that build no solid stay as they are. - fn clipped(&self, n: &Node, clip: &(PathData, FillRule), depth: usize) -> Result, String> { + /// 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![]; - for c in n.children().into_iter().flatten() { - if let Some(c) = self.clipped(c, clip, depth + 1)? { - kids.push(Arc::new(c)); + 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))); @@ -202,6 +277,20 @@ 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] { n.children().map_or(&[], Vec::as_slice) @@ -224,3 +313,11 @@ fn wrap(n: &Node, children: Vec>) -> Node { 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 +} diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 2d44a3503..b8063b2c5 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -226,3 +226,135 @@ fn images_are_refused_alone_and_in_a_group_with_image_trace_named() { assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id}"); } } + +/// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one +/// before it. +fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "L0".into(), art: Arc::new(Node::group(NodeId(0), vec![Arc::new(leaf)])) }); + for level in 1..=levels { + let below = format!("L{}", level - 1); + let members = (0..fan).map(|i| Arc::new(instance(0, &below, Affine::translate((i as f64 * 2.0, 0.0))))).collect(); + d.symbols.push(Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(Node::group(NodeId(0), members)) }); + } + d +} + +#[test] +fn nested_symbol_instances_that_repeat_hidden_art_are_refused_past_the_work_limit() { + // Four instances at each of ten levels repeat the hidden path about a million times, with no + // object to count against the 4,096-object limit. + let mut hidden = rect(0, 0.0, 0.0, 10.0, 10.0, 0.0); + hidden.visible = false; + let d = nested_symbols(10, 4, hidden); + for id in TYPES { + let n = with(instance(30, "L10", Affine::IDENTITY), id, json!({})); + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{id}: {err}"); + assert!(source::WORK.with(std::cell::Cell::get) <= source::MAX_WORK + 1, "{id}: stopped at the limit"); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}"); + } + // The same nesting with 1,024 visible squares stays far below the limit and builds: each + // square, group and instance is visited once (an instance twice: listed, then resolved). + let d = nested_symbols(5, 4, rect(0, 0.0, 0.0, 10.0, 10.0, 0.0)); + let n = with(instance(30, "L5", Affine::IDENTITY), ROTATE, front()); + assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(())); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 1024 + 1365 * 3, "{work}"); + assert!(solid_art_in(Some(&d), &n).is_some()); +} + +#[test] +fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Empty".into(), art: Arc::new(Node::group(NodeId(0), vec![])) }); + let clip = Rect::new(0.0, 0.0, 50.0, 100.0); + d.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clip_group(0, clip, rect(0, 0.0, 0.0, 100.0, 100.0, 0.0))) }); + let empty_clip = Node::new(NodeId(10), NodeKind::Group { children: vec![], clip: true }); + let empty_symbol = instance(30, "Empty", Affine::IDENTITY); + let no_keys = Node::new(NodeId(20), NodeKind::Blend { children: vec![], spec: Box::default() }); + for id in TYPES { + // Alone, an empty clipping group, an instance of an empty symbol and a blend without keys + // build nothing and say so. + for empty in [&empty_clip, &empty_symbol, &no_keys] { + let n = with(empty.clone(), id, json!({})); + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("at least two anchors"), "{id} on {:?}: {err}", empty.kind); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}"); + // In a group they add nothing, and the group's other members still build. + let g = with(Node::group(NodeId(90), vec![Arc::new(empty.clone()), Arc::new(rect(91, 0.0, 0.0, 50.0, 50.0, 0.0))]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {:?}", empty.kind); + assert!(art(&d, &g, id).is_some(), "{id}"); + } + // A blend set to no steps draws one step, and builds from its keys and that step. + let n = with(blend(20, 0), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}"); + } + let drawn = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, 0)).into_iter().map(Arc::new).collect()); + assert_eq!(drawn.children().unwrap().len(), 3); + let (a, b) = (solid_art_in(Some(&d), &with(blend(20, 0), ROTATE, front())).unwrap(), solid_art(&with(drawn, ROTATE, front())).unwrap()); + assert_eq!(a.children().unwrap().len(), b.children().unwrap().len()); + assert!(close(bounds(&a), bounds(&b), 0.01)); + // A symbol holding a clipping group builds from the art inside the clip, where the instance + // places it. + let placed = with(instance(30, "Clipped", Affine::translate((200.0, 0.0))), ROTATE, front()); + let cut = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 200.0, 0.0, 250.0, 100.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); +} + +#[test] +fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Left".into(), art: Arc::new(rect(0, 0.0, 100.0, 50.0, 200.0, 0.0)) }); + let moved = |n: Node| with(n, "distort.transform", json!({"moveH": 100.0})); + let clip = Rect::new(100.0, 100.0, 150.0, 200.0); + let target = rect(1, 100.0, 100.0, 150.0, 200.0, 0.0); + let expected = bounds(&solid_art(&with(Node::group(NodeId(90), vec![Arc::new(target.clone())]), ROTATE, front())).unwrap()); + let left = || rect(2, 0.0, 100.0, 50.0, 200.0, 0.0); + let members = [ + ("a symbol instance", moved(instance(30, "Left", Affine::IDENTITY))), + ("a clipping group", moved(clip_group(40, Rect::new(0.0, 100.0, 50.0, 200.0), left()))), + ("a plain group", moved(Node::group(NodeId(50), vec![Arc::new(left())]))), + ("a group in a clipping group", clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(left())])))), + ]; + for (what, member) in members { + for id in TYPES { + let g = with(Node::group(NodeId(90), vec![Arc::new(member.clone())]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {what}"); + } + let g = with(Node::group(NodeId(90), vec![Arc::new(member)]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &g).unwrap()), expected, 0.01), "{what} moved by its Transform effect"); + } + // A blend's effect moves its keys and steps together. + let blended = with(Node::group(NodeId(90), vec![Arc::new(moved(blend(20, 3)))]), ROTATE, front()); + let unmoved = with(Node::group(NodeId(90), vec![Arc::new(blend(20, 3))]), ROTATE, front()); + let (a, b) = (bounds(&solid_art_in(Some(&d), &blended).unwrap()), bounds(&solid_art_in(Some(&d), &unmoved).unwrap())); + assert!(close(a, Rect::new(b.x0 + 100.0, b.y0, b.x1 + 100.0, b.y1), 0.01), "{a:?} is {b:?} moved"); + // A geometry effect that reads the object's stroke reads the resolved object's, as a group's. + let stroked = |kind: NodeKind| { + let mut n = Node::new(NodeId(20), kind); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0).into(); + with(n, "path.outlineStroke", json!({})) + }; + let NodeKind::Blend { children, spec } = blend(20, 3).kind else { panic!("a blend") }; + let steps = crate::expand_live(Some(&d), &blend(20, 3)).into_iter().map(Arc::new).collect(); + let group_of = |n: Node| with(Node::group(NodeId(90), vec![Arc::new(n)]), ROTATE, front()); + let resolved = bounds(&solid_art_in(Some(&d), &group_of(stroked(NodeKind::Blend { children, spec }))).unwrap()); + let plain = bounds(&solid_art(&group_of(stroked(NodeKind::Group { children: steps, clip: false }))).unwrap()); + assert!(close(resolved, plain, 0.01), "{resolved:?} {plain:?}"); + // Groups nested past the limit under a group with geometry effects in a clipping group are + // refused before the effects reshape them. + let mut deep = left(); + for i in 0..200 { + deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]); + } + let n = with(clip_group(60, clip, moved(deep)), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); + // Hidden members of a group inside a clipping group build nothing. + let mut hidden = rect(63, 100.0, 100.0, 150.0, 200.0, 0.0); + hidden.visible = false; + let n = with(clip_group(60, clip, Node::group(NodeId(62), vec![Arc::new(hidden)])), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at least two anchors"))); +} diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 659156fcc..c03a6cc0f 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -612,8 +612,8 @@ fn an_export_and_rasterize_hold_the_solid_of_3d_type() { assert!(rasterized.width() > letters.width() + 15.0, "{letters:?} {rasterized:?}"); } -/// A symbol instance, a blend and a clipping group with Extrude & Bevel draw their solids on the -/// canvas, in an export and in Object › Rasterize, not their flat art. +/// A symbol instance, a blend and a clipping group with Extrude & Bevel, alone or in a group, draw +/// their solids on the canvas, in an export and in Object › Rasterize, not their flat art. #[test] fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() { use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind, Symbol}; @@ -631,12 +631,17 @@ fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() { *clipping = true; } let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }); + let instance = NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((40.0, 40.0)) }; + let clipped = NodeKind::Group { children: vec![Arc::new(clip), square(20.0, 20.0)], clip: true }; + let in_group = |kind: NodeKind| NodeKind::Group { children: vec![Arc::new(Node::new(NodeId(0), kind)), square(100.0, 100.0)], clip: false }; let kinds = [ - NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((40.0, 40.0)) }, - NodeKind::Blend { children: vec![square(30.0, 30.0), square(60.0, 40.0)], spec }, - NodeKind::Group { children: vec![Arc::new(clip), square(20.0, 20.0)], clip: true }, + ("a symbol instance", instance.clone()), + ("a blend", NodeKind::Blend { children: vec![square(30.0, 30.0), square(60.0, 40.0)], spec }), + ("a clipping group", clipped.clone()), + ("a group holding a symbol instance", in_group(instance)), + ("a group holding a clipping group", in_group(clipped)), ]; - for kind in kinds { + for (what, kind) in kinds { let mut n = Node::new(d.alloc_id(), kind); let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap(); n.appearance.effects = @@ -645,8 +650,55 @@ fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() { let n = &solid_doc.layers[0].children().unwrap()[0]; let (solid, rasterized) = (crate::encode::art_bounds(&solid_doc).unwrap(), crate::drawn_bounds(&solid_doc, n).unwrap()); let size = |r: Rect| r.width() + r.height(); - assert!(size(solid) > size(flat) + 20.0, "{:?}: {flat:?} {solid:?}", n.kind); - assert!(size(rasterized) > size(flat) + 20.0, "{:?}: {flat:?} {rasterized:?}", n.kind); + assert!(size(solid) > size(flat) + 20.0, "{what}: {flat:?} {solid:?}"); + assert!(size(rasterized) > size(flat) + 20.0, "{what}: {flat:?} {rasterized:?}"); + // The renderer builds the solid from the object itself, with the document's symbols, as + // the effect builds it: one solid built, not the object's art drawn again with the effect. + let built = vectorcraft_effects::solid_art_in(Some(&solid_doc), n).and_then(|a| a.visual_bounds()).unwrap(); + assert!((solid.x0 - built.x0).abs() < 1.0 && (solid.x1 - built.x1).abs() < 1.0, "{what}: {built:?} {solid:?}"); + let mut r = Renderer::new(); + r.art_bounds_on(&solid_doc, Rect::new(0.0, 0.0, 200.0, 200.0)); + assert_eq!(r.stats.three_d_built, 1, "{what}"); + } +} + +/// A loaded file can hold a 3D effect on art past the limits commands check: a blend of more +/// than 4,096 objects, art nested more than 64 levels deep in a clipping group. The art then draws +/// as it does without the effect, and drawing it does not fail. +#[test] +fn art_past_the_three_d_limits_draws_as_without_the_effect() { + use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind}; + let square = |x: f64| { + Arc::new(Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0), + )) + }; + let mut clip = (*square(0.0)).clone(); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut deep = (*square(0.0)).clone(); + for _ in 0..200 { + deep = Node::group(NodeId(0), vec![Arc::new(deep)]); + } + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }); + let kinds = [ + ("a blend of 6,000 objects", NodeKind::Blend { children: (0..6).map(|i| square(30.0 * i as f64)).collect(), spec }), + ("art 200 levels deep in a clip", NodeKind::Group { children: vec![Arc::new(clip), Arc::new(deep)], clip: true }), + ]; + let d = Document::new(200.0, 200.0); + for (what, kind) in kinds { + let mut n = Node::new(NodeId(500), kind); + let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap(); + n.appearance.effects = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }]; + let loaded = doc_of(&mut d.clone(), n); + let n = &loaded.layers[0].children().unwrap()[0]; + assert!(vectorcraft_effects::validate_three_d_in(Some(&loaded), n, "threeD.extrude", &json!({})).is_err(), "{what}"); + assert_eq!(crate::encode::art_bounds(&loaded), Some(flat), "{what}"); + assert!(crate::drawn_bounds(&loaded, n).is_some(), "{what}"); } } diff --git a/docs/three-d.md b/docs/three-d.md index f94b17e38..b7bbde245 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -118,7 +118,8 @@ it, at any depth of its groups, each from its own fills and strokes as above ([F and they turn, twist and taper together about the center of their bounds together; with Perspective one eye sees them all. The members stay editable: moving or editing one reshapes the solids. Our reading: -- Each member's own geometry effects shape it first; the group's geometry effects before the 3D effect reshape the +- Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, + applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn @@ -144,6 +145,10 @@ solids are ([Groups](#groups)), turned about one shared axis. - A symbol instance builds from its symbol's art where the instance places it. Redefining the symbol reshapes the solid. An instance of a symbol that is not in the document, a symbol that holds an instance of itself, or art nested more than 64 levels deep is refused. +- Inside a 3D group, the geometry effects of a clipping group, a blend or a symbol instance reshape the art it shows, + as a group's do. Inside a clipping group, a group's geometry effects reshape its members before the clip cuts them. +- Art whose symbol instances, blends and clipping groups take more than 262,144 objects to go through (counting + hidden objects, and a symbol's art once for each instance that shows it) is refused. - The objects stay live: editing the clipping path, the blend's keys or the symbol reshapes the solid. - An image is refused, alone or in a group: trace it first with Object › Image Trace › Make. The help page routes images to 3D through Image Trace ([Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html)).