mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
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.
This commit is contained in:
@@ -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::<GradientStop>();
|
||||
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};
|
||||
|
||||
@@ -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<Vec<Profile>, String> {
|
||||
@@ -302,7 +303,12 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, 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 {
|
||||
|
||||
@@ -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<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<'_> {
|
||||
/// 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<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")
|
||||
})?;
|
||||
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<Arc<Node>> = 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<Node>], depth: usize) -> Result<Vec<Arc<Node>>, 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<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 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<Option<Node>, 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<Option<Node>, 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<Node>] {
|
||||
n.children().map_or(&[], Vec::as_slice)
|
||||
@@ -224,3 +313,11 @@ fn wrap(n: &Node, children: Vec<Arc<Node>>) -> 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
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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)).
|
||||
|
||||
Reference in New Issue
Block a user