From f0dad6010fbbad3e1a3bacd0dbcf61155002ef81 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 23:22:20 -0800 Subject: [PATCH 01/10] 3D: effects build from clipping groups, blends and symbol instances Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve now apply to a clipping group (or a layer with a clipping mask), a blend or a symbol instance, alone or inside a group or layer. The solid is built from the art the object shows: the art inside the clipping path (fills cut to the clip, strokes as their outlines cut to the clip), the blend's keys and steps, and the symbol's art where the instance places it. Validation, the canvas, bounds, Expand Appearance, Rasterize and assets.add3D read the same art from the document. An image is refused, alone or in a group, with a message to trace it first with Object > Image Trace > Make. A missing symbol, a symbol that holds an instance of itself, art nested more than 64 levels deep and more than 4,096 objects are refused. The refusal messages change, and their rows change in every catalog. Docs: docs/three-d.md, docs/revolve.md, docs/mcp.md, docs/target-app-parity.md. --- crates/effects/src/bake.rs | 6 +- crates/effects/src/lib.rs | 7 +- crates/effects/src/revolve.rs | 34 ++- crates/effects/src/solids.rs | 107 ++++++-- crates/effects/src/solids/source.rs | 226 +++++++++++++++++ crates/effects/src/solids/tests.rs | 8 +- crates/effects/src/solids/tests_mesh.rs | 10 +- crates/effects/src/solids/tests_sources.rs | 228 ++++++++++++++++++ crates/engine/src/cmd/assets.rs | 5 +- crates/engine/src/cmd/effectcmd.rs | 13 +- .../src/cmd/effectcmd/three_d_source_tests.rs | 98 ++++++++ crates/render/src/fx/bounds.rs | 3 +- crates/render/src/fx/renderer.rs | 2 +- crates/render/src/fx/tests.rs | 38 +++ crates/ui-egui/src/i18n/bg.tsv | 3 +- crates/ui-egui/src/i18n/bs.tsv | 3 +- crates/ui-egui/src/i18n/ca.tsv | 3 +- crates/ui-egui/src/i18n/cs.tsv | 3 +- crates/ui-egui/src/i18n/da.tsv | 3 +- crates/ui-egui/src/i18n/de.tsv | 3 +- crates/ui-egui/src/i18n/el.tsv | 3 +- crates/ui-egui/src/i18n/es.tsv | 3 +- crates/ui-egui/src/i18n/fi.tsv | 3 +- crates/ui-egui/src/i18n/fr.tsv | 3 +- crates/ui-egui/src/i18n/hi.tsv | 3 +- crates/ui-egui/src/i18n/hu.tsv | 3 +- crates/ui-egui/src/i18n/id.tsv | 3 +- crates/ui-egui/src/i18n/it.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 3 +- crates/ui-egui/src/i18n/ka.tsv | 3 +- crates/ui-egui/src/i18n/ko.tsv | 3 +- crates/ui-egui/src/i18n/nl.tsv | 3 +- crates/ui-egui/src/i18n/pl.tsv | 3 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/ru.tsv | 3 +- crates/ui-egui/src/i18n/sv.tsv | 3 +- crates/ui-egui/src/i18n/tr.tsv | 3 +- crates/ui-egui/src/i18n/uk.tsv | 3 +- crates/ui-egui/src/i18n/vi.tsv | 3 +- crates/ui-egui/src/i18n/zh-hans.tsv | 3 +- crates/ui-egui/src/i18n/zh-hant.tsv | 3 +- docs/mcp.md | 9 +- docs/revolve.md | 4 +- docs/target-app-parity.md | 1 + docs/three-d.md | 37 ++- 45 files changed, 825 insertions(+), 92 deletions(-) create mode 100644 crates/effects/src/solids/source.rs create mode 100644 crates/effects/src/solids/tests_sources.rs create mode 100644 crates/engine/src/cmd/effectcmd/three_d_source_tests.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f66937446..afe256e59 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -124,7 +124,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crate::addons::halftone::halftone_art(d, n) { return Some(bake_pieces(d, m)); } - if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } @@ -481,7 +481,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { - if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve::expand_revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } @@ -551,7 +551,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to /// outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { - if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { + if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) { return Some(m); } if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 82db95b5b..ae8b7d9e9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,11 +83,12 @@ pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; -pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; +pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in}; pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, - classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, - validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, + classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, + three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, + validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 89f3bba86..f70947176 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -75,7 +75,14 @@ pub fn revolve_options(p: &Value) -> Revolve { /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { + validate_revolve_in(None, n, p) +} + +/// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group +/// revolve the art they show ([`resolve`]). +pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; + let n = &*resolve(doc, n, "Revolve")?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); @@ -318,12 +325,26 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::Blend /// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs /// to its entire surface, not to each face, so overlapping faces don't accumulate alpha. pub fn revolve_art(n: &Node) -> Option { - art(n, false) + revolve_art_in(None, n) } -/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged. +/// [`revolve_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone +/// or in a group, revolve the art they show ([`resolve`]). `None` too when that art can't be +/// resolved. +pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { + art(doc, n, false) +} + +/// [`expand_revolve_art_in`] without a document. +#[cfg(test)] pub(crate) fn expand_revolve_art(n: &Node) -> Option { - art(n, true) + expand_revolve_art_in(None, n) +} + +/// Revolve's own Expand Appearance path, with the symbols of `doc` ([`revolve_art_in`]). The shared +/// command and UI remain unchanged. +pub(crate) fn expand_revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { + art(doc, n, true) } /// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on @@ -388,10 +409,11 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result Option { +fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } + let n = &*resolve(doc, n, "Revolve").ok()?; if is_container(n) { return group_art(n, expand); } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ab119192c..7c463dccf 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; @@ -17,6 +17,9 @@ use crate::{GeomContext, apply_geometry_with, is_geometry}; mod classic; pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; +mod source; +pub(crate) use source::resolve; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -198,10 +201,22 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { Ok(()) } +/// The name an error gives 3D effect `id`. +pub(crate) fn label_of(id: &str) -> &'static str { + crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')) +} + /// Validate before a command changes the document; the evaluator bounds loaded files too. pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_in(None, n, id, p) +} + +/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group +/// build from the art they show ([`resolve`]). +pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { validate_solid_params(id, p)?; - let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); + let label = label_of(id); + let n = &*resolve(doc, n, label)?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -213,14 +228,13 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { } /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from -/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind. +/// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other +/// object is the wrong kind. pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> { - profile(n).ok_or_else(|| { - if matches!(n.kind, NodeKind::Text(_)) { - format!("{label} requires a shape with at least two anchors") - } else { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - } + profile(n).ok_or_else(|| match n.kind { + NodeKind::Text(_) => format!("{label} requires a shape with at least two anchors"), + NodeKind::Image(_) => source::image_refusal(label), + _ => format!("{label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first."), }) } @@ -264,7 +278,8 @@ const MAX_MEMBERS: usize = 4096; 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, at any depth of its groups, each through its own geometry +/// 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 /// 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. @@ -292,13 +307,13 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { } let Some((path, rule)) = profile(c) else { let what = match &c.kind { - NodeKind::Group { clip: true, .. } => "clipping groups", - NodeKind::Image(_) => "images", - NodeKind::SymbolInstance { .. } => "symbol instances", + NodeKind::Image(_) => return Err(source::image_refusal(label)), NodeKind::Path { clipping: true, .. } => "clipping paths", - _ => "objects other than paths, compound paths and type", + _ => "other objects", }; - return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); + return Err(format!( + "{label} on a group needs paths, compound paths, type, blends and symbol instances: release or expand its {what} first." + )); }; if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); @@ -319,7 +334,12 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { /// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by /// [`validate_solid`]. pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } + validate_three_d_in(None, n, id, p) +} + +/// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). +pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) } } /// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of @@ -328,10 +348,18 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// item's geometry effects before it, together with the object's other items that carry a Revolve. /// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + validate_three_d_at_in(None, n, id, p, at) +} + +/// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a +/// blend and a clipping group take a 3D effect as groups do: on the object itself. +pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { match at { - Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()), + Some(_) if is_container(n) || source::resolves(n) => { + Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) + } Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), - _ => validate_three_d(n, id, p), + _ => validate_three_d_in(doc, n, id, p), } } @@ -535,9 +563,17 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// On a group or layer the effect builds a solid from each path, compound path and type in it, /// all turned about one shared axis ([`members`]). pub fn solid_art(n: &Node) -> Option { + solid_art_in(None, n) +} + +/// [`solid_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone +/// or in a group, build from the art they show ([`resolve`]). `None` too when that art can't be +/// resolved. +pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { if !has_solid(n) { return None; } + let n = &*resolve(doc, n, "").ok()?; if is_container(n) { return group_solid_art(n); } @@ -689,28 +725,44 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) /// such effect, its solids can't be built, none has a triangle, or together they have more than /// [`MAX_MESH_TRIANGLES`]. pub fn three_d_meshes(n: &Node) -> Result, String> { - meshes_within(n, &mut 0, MAX_MESH_TRIANGLES) + three_d_meshes_in(None, n) +} + +/// [`three_d_meshes`] with the symbols of `doc`: a symbol instance, a blend and a clipping group +/// build from the art they show ([`resolve`]). +pub fn three_d_meshes_in(doc: Option<&Document>, n: &Node) -> Result, String> { + meshes_within(doc, n, &mut 0, MAX_MESH_TRIANGLES) } /// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports /// them: the objects share one budget of [`MAX_MESH_TRIANGLES`]. pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator) -> Result, String> { - meshes_of_within(nodes, MAX_MESH_TRIANGLES) + three_d_meshes_of_in(None, nodes) } -/// [`three_d_meshes_of`] with a budget of `limit` triangles. -fn meshes_of_within<'a>(nodes: impl IntoIterator, limit: usize) -> Result, String> { +/// [`three_d_meshes_of`] with the symbols of `doc` ([`three_d_meshes_in`]). +pub fn three_d_meshes_of_in<'a>(doc: Option<&Document>, nodes: impl IntoIterator) -> Result, String> { + meshes_of_within(doc, nodes, MAX_MESH_TRIANGLES) +} + +/// [`three_d_meshes_of_in`] with a budget of `limit` triangles. +fn meshes_of_within<'a>(doc: Option<&Document>, nodes: impl IntoIterator, limit: usize) -> Result, String> { let mut total = 0usize; let mut out = vec![]; for n in nodes { - out.extend(meshes_within(n, &mut total, limit)?); + out.extend(meshes_within(doc, n, &mut total, limit)?); } Ok(out) } -/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an +/// [`three_d_meshes_in`], with the triangles of every solid added to `total` as it is built and an /// error as soon as `total` passes `limit`. -fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result, String> { +fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usize) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let n = &*resolve(doc, n, label)?; let meshes = if crate::has_revolve(n) { crate::revolve::revolve_meshes(n, total, limit)? } else if !has_solid(n) { @@ -722,7 +774,7 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result 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, String> { + if !needs(n) { + return Ok(Cow::Borrowed(n)); + } + let mut r = Resolver { doc, label, leaves: 0 }; + let children = r.content(n, 0)?; + let mut out = n.clone(); + out.kind = NodeKind::Group { children, clip: false }; + Ok(Cow::Owned(out)) +} + +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, +} + +impl Resolver<'_> { + /// The art `n` 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()); + } + 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) + } + 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()); + } + let steps: Vec> = crate::expand_live(self.doc, n).into_iter().map(Arc::new).collect(); + self.list(&steps, depth + 1) + } + _ 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) + } + _ => self.list(kids(n), depth + 1), + } + } + + /// 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> { + let mut out = Vec::with_capacity(children.len()); + for c in children { + 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) { + let kids = self.content(c, depth)?; + out.push(Arc::new(wrap(c, kids))); + continue; + } + if !is_container(c) { + self.leaves += 1; + if self.leaves > MAX_MEMBERS { + return Err(self.too_many()); + } + } + out.push(c.clone()); + } + Ok(out) + } + + 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; + /// `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> { + if depth > MAX_DEPTH { + return Err(self.too_deep()); + } + 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)); + } + } + 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, 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 = 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)) +} + +/// The members of `n`, none for an object without members. +fn kids(n: &Node) -> &[Arc] { + n.children().map_or(&[], Vec::as_slice) +} + +/// A path of `n`'s id on `path`, painted by `items` under `rule`. +fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec) -> Node { + let mut piece = Node::path(n.id, path, Appearance { items, ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + piece +} + +/// A plain group of `children` with `n`'s id, visibility, opacity and blending mode. +fn wrap(n: &Node, children: Vec>) -> Node { + let mut g = Node::group(n.id, children); + g.name = n.name.clone(); + g.opacity = n.opacity; + g.blend = n.blend; + g +} diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 29bbdb3bd..5d1227789 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -591,13 +591,13 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { // Hidden members and guides are left out; a group holding what can't become a solid is // refused with what to do. let mut g = group(EXTRUDE, json!({})); - let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); - clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; + let mut woven = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); + woven.kind = NodeKind::Intertwine { children: woven.children().unwrap().clone() }; if let NodeKind::Group { children, .. } = &mut g.kind { - children.push(Arc::new(clip)); + children.push(Arc::new(woven)); } let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err(); - assert!(err.contains("clipping groups"), "{err}"); + assert!(err.contains("release or expand its other objects"), "{err}"); assert!(solid_art(&g).is_none(), "drawn as the group it is"); } diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 7b223f389..1ae347c97 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -79,7 +79,7 @@ fn revolve_stops_at_the_first_mesh_past_the_budget() { assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}"); // With room for less than the fill's mesh, the stroke's is never built. let mut total = 0; - let capped = meshes_within(&n, &mut total, triangles - 1); + let capped = meshes_within(None, &n, &mut total, triangles - 1); assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}"); assert_eq!(total, triangles); } @@ -92,10 +92,10 @@ fn the_objects_of_one_asset_share_one_triangle_budget() { b.appearance.effects.push(fx(EXTRUDE, json!({}))); let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::(); let (ta, tb) = (triangles(&a), triangles(&b)); - assert!(meshes_of_within([&a], ta).is_ok()); - assert!(meshes_of_within([&b], tb).is_ok()); - assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); - assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4)); + assert!(meshes_of_within(None, [&a], ta).is_ok()); + assert!(meshes_of_within(None, [&b], tb).is_ok()); + assert!(meshes_of_within(None, [&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); + assert_eq!(meshes_of_within(None, [&a, &b], ta + tb).map(|m| m.len()), Ok(4)); assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4)); } diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs new file mode 100644 index 000000000..2d44a3503 --- /dev/null +++ b/crates/effects/src/solids/tests_sources.rs @@ -0,0 +1,228 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{AppearanceItem, BlendSpacing, BlendSpec, Document, FillLayer, ImageObject, NodeId, StrokeLayer, Symbol}; +use vectorcraft_geom::{Affine, shapes}; + +use super::*; + +const TYPES: [&str; 5] = [EXTRUDE, CLASSIC_EXTRUDE, INFLATE, ROTATE, crate::REVOLVE]; + +/// Rotate facing the viewer: the art drawn where it is. +fn front() -> Value { + json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}) +} + +fn rect(id: u64, x0: f64, y0: f64, x1: f64, y1: f64, stroke: f64) -> Node { + let path = shapes::rectangle(Rect::new(x0, y0, x1, y1)); + let stroke_paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + Node::path(NodeId(id), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), stroke_paint, stroke)) +} + +fn with(mut n: Node, id: &str, params: Value) -> Node { + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +fn art(d: &Document, n: &Node, id: &str) -> Option { + if id == crate::REVOLVE { crate::revolve_art_in(Some(d), n) } else { solid_art_in(Some(d), n) } +} + +fn bounds(n: &Node) -> Rect { + n.visual_bounds().unwrap() +} + +fn close(a: Rect, b: Rect, tolerance: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() <= tolerance) +} + +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(rect(0, 0.0, 0.0, 100.0, 100.0, 2.0)) }); + d +} + +/// A clipping group: `art` inside a rectangular clipping path covering `clip`. +fn clip_group(id: u64, clip: Rect, art: Node) -> Node { + let mut path = Node::path(NodeId(id + 1), shapes::rectangle(clip), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(path), Arc::new(art)], clip: true }) +} + +fn blend(id: u64, steps: u32) -> Node { + let keys = vec![Arc::new(rect(id + 1, 100.0, 100.0, 140.0, 140.0, 0.0)), Arc::new(rect(id + 2, 300.0, 120.0, 360.0, 180.0, 0.0))]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(steps), ..Default::default() }; + Node::new(NodeId(id), NodeKind::Blend { children: keys, spec: Box::new(spec) }) +} + +fn instance(id: u64, symbol: &str, xf: Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn image(id: u64) -> Node { + Node::new( + NodeId(id), + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ) +} + +fn sources() -> [(&'static str, Node); 3] { + [ + ("clipping group", clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0))), + ("blend", blend(20, 3)), + ("symbol instance", instance(30, "Box", Affine::translate((300.0, 0.0)))), + ] +} + +#[test] +fn every_three_d_type_builds_clipping_groups_blends_and_symbol_instances_alone_and_in_a_group() { + let d = document(); + for (what, source) in sources() { + for id in TYPES { + let n = with(source.clone(), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id} on a {what}"); + assert!(art(&d, &n, id).is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws"); + assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id} on a {what} exports"); + let g = with(Node::group(NodeId(90), vec![Arc::new(source.clone()), Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0))]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding a {what}"); + assert!(art(&d, &g, id).is_some(), "{id} on a group holding a {what} draws"); + assert!(three_d_meshes_in(Some(&d), &g).is_ok_and(|m| !m.is_empty()), "{id} on a group holding a {what} exports"); + } + } +} + +#[test] +fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() { + let d = document(); + let clip = Rect::new(100.0, 100.0, 150.0, 200.0); + let fill_only = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0)), ROTATE, front()); + let cut = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &fill_only).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); + // The stroke is cut to the clip too: nothing reaches past it but the faces' seams. + let stroked = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 8.0)), EXTRUDE, front()); + let b = bounds(&solid_art_in(Some(&d), &stroked).unwrap()); + let outside = b.x0 < clip.x0 - 1.0 || b.x1 > clip.x1 + 1.0; + let extruded = + bounds(&solid_art(&with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), EXTRUDE, front())).unwrap()); + assert!(!outside && close(b, extruded, 1.0), "{b:?} within {clip:?}"); + // A clip inside a clip keeps what both show. + let nested = + with(clip_group(40, Rect::new(120.0, 0.0, 400.0, 400.0), clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))), ROTATE, front()); + let both = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 120.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front()); + assert!(close(bounds(&solid_art_in(Some(&d), &nested).unwrap()), bounds(&solid_art(&both).unwrap()), 0.01)); + // A clipping group used as the clipping path clips by its own clipping path, not by its art. + let mask = Arc::new(clip_group(70, clip, rect(72, 100.0, 100.0, 110.0, 110.0, 0.0))); + let by_mask = with( + Node::new(NodeId(10), NodeKind::Group { children: vec![mask, Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }), + ROTATE, + front(), + ); + assert!(close(bounds(&solid_art_in(Some(&d), &by_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); + // A stroke between two fills keeps its place below the top fill's solid. + let mut layered = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0); + layered.appearance = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))), + AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), 6.0)), + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))), + ], + ..Default::default() + } + .into(); + let colors = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), EXTRUDE, json!({}))).unwrap().iter().map(|m| m.color).collect::>(); + let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); + assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's"); + assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered)), plain); + // Art nested past the limit inside a clipping group is refused, not followed to any depth. + let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); + for i in 0..200 { + deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]); + } + let n = with(clip_group(10, clip, deep), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); + assert!(solid_art_in(Some(&d), &n).is_none()); + // A clip that shows none of the art builds nothing, and says so. + let empty = clip_group(10, Rect::new(500.0, 500.0, 550.0, 550.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0)); + for id in TYPES { + let n = with(empty.clone(), id, json!({})); + assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("at least two anchors")), "{id}"); + assert!(art(&d, &n, id).is_none(), "{id}"); + } +} + +#[test] +fn a_symbol_instances_solid_is_its_symbols_art_where_the_instance_places_it() { + let d = document(); + let placed = with(instance(30, "Box", Affine::translate((300.0, 50.0))), EXTRUDE, json!({})); + let same = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 300.0, 50.0, 400.0, 150.0, 2.0))]), EXTRUDE, json!({})); + assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&same).unwrap()), 0.01)); + // A redefined symbol reshapes the solid. + let mut wider = d.clone(); + wider.symbols[0].art = Arc::new(rect(0, 0.0, 0.0, 200.0, 100.0, 2.0)); + assert!(bounds(&solid_art_in(Some(&wider), &placed).unwrap()).width() > bounds(&solid_art_in(Some(&d), &placed).unwrap()).width() + 50.0); + // Without the symbol there is nothing to build from. + let missing = with(instance(30, "Gone", Affine::IDENTITY), EXTRUDE, json!({})); + assert!(validate_three_d_in(Some(&d), &missing, EXTRUDE, &json!({})).is_err_and(|e| e.contains("\"Gone\""))); + assert!(validate_three_d(&placed, EXTRUDE, &json!({})).is_err(), "no document: no symbol art"); + // A symbol holding an instance of itself is refused, not followed without end. + let mut looped = d.clone(); + let inner = Arc::new(instance(0, "Loop", Affine::translate((10.0, 10.0)))); + looped.symbols.push(Symbol { + dynamic: false, + name: "Loop".into(), + art: Arc::new(Node::group(NodeId(0), vec![Arc::new(rect(0, 0.0, 0.0, 10.0, 10.0, 0.0)), inner])), + }); + let n = with(instance(30, "Loop", Affine::IDENTITY), EXTRUDE, json!({})); + assert!(validate_three_d_in(Some(&looped), &n, EXTRUDE, &json!({})).is_err()); + assert!(solid_art_in(Some(&looped), &n).is_none()); +} + +#[test] +fn a_blends_solid_is_built_from_its_keys_and_steps() { + let d = document(); + for steps in [1, 3, 8] { + let n = with(blend(20, steps), ROTATE, front()); + let expanded = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, steps)).into_iter().map(Arc::new).collect()); + assert_eq!(expanded.children().unwrap().len(), steps as usize + 2); + let same = with(expanded, ROTATE, front()); + let (a, b) = (solid_art_in(Some(&d), &n).unwrap(), solid_art(&same).unwrap()); + assert_eq!(a.children().unwrap().len(), b.children().unwrap().len(), "a surface for each key and step"); + assert!(close(bounds(&a), bounds(&b), 0.01)); + } + // A blend making more objects than a 3D effect takes is refused. + let keys = (0..6).map(|i| Arc::new(rect(21 + i, 100.0 * i as f64, 0.0, 100.0 * i as f64 + 40.0, 40.0, 0.0))).collect(); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }; + let n = with(Node::new(NodeId(20), NodeKind::Blend { children: keys, spec: Box::new(spec) }), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at most 4096 objects"))); +} + +#[test] +fn images_are_refused_alone_and_in_a_group_with_image_trace_named() { + let d = document(); + for id in TYPES { + for n in [ + with(image(50), id, json!({})), + with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(image(53))]), id, json!({})), + with(clip_group(60, Rect::new(0.0, 0.0, 10.0, 10.0), image(62)), id, json!({})), + ] { + let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err(); + assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}"); + assert!(art(&d, &n, id).is_none(), "{id}"); + assert!(three_d_meshes_in(Some(&d), &n).is_err_and(|e| e.contains("Image Trace")), "{id}"); + } + // A hidden image is not drawn, so it builds nothing and refuses nothing. + let mut hidden = image(53); + hidden.visible = false; + let g = with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(hidden)]), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id}"); + } +} diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 31639519f..0b57565af 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -213,7 +213,7 @@ fn add_3d(s: &mut Session, p: &Value) -> Result { ids.dedup(); for id in &ids { let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; - if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + if let Err(e) = vectorcraft_render::effects::three_d_meshes_in(Some(d), n) { let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); } @@ -281,7 +281,8 @@ fn export_3d(s: &mut Session, p: &Value) -> Result { a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; // The asset's objects share one budget of triangles. let what = &a.name; - let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; + let parts = + vectorcraft_render::effects::three_d_meshes_of_in(Some(d), nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); let mut stem = base.clone(); let mut k = 1; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9d396edc1..8bf5c9a3d 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -282,11 +282,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if effects::is_three_d(id) { let targets = appearance_targets(s, p)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { - let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; + let d = &s.doc()?.doc; + let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's others. let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); - effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -575,7 +576,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result { params["name"] = json!(svg_filter_name(s, xml, C)?); } for &(id, target, index) in &targets { - let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let d = &s.doc()?.doc; + let n = d.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); @@ -593,7 +595,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. @@ -1006,6 +1008,9 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod lighting_presets_tests; +#[cfg(test)] +mod three_d_source_tests; + #[cfg(test)] mod three_d_type_tests; diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs new file mode 100644 index 000000000..122d937b9 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs @@ -0,0 +1,98 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"]; + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// A clipping group, a blend and a symbol instance, each made through its command. +fn sources(s: &mut Session) -> Vec<(&'static str, NodeId)> { + let art = rect(s, 20.0, 20.0, 60.0, 60.0); + let clip = rect(s, 40.0, 10.0, 20.0, 80.0); + select(s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clipped = s.doc().unwrap().selection.objects[0]; + let a = rect(s, 120.0, 20.0, 20.0, 20.0); + let b = rect(s, 200.0, 60.0, 30.0, 30.0); + select(s, &[a, b]); + let blend = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + let art = rect(s, 20.0, 200.0, 50.0, 40.0); + let instance = id_of(&s.execute("symbol.new", &json!({"name": "Box", "ids": [art.0]})).unwrap()); + vec![("clipping group", clipped), ("blend", blend), ("symbol instance", instance)] +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +#[test] +fn every_three_d_type_applies_to_clipping_groups_blends_and_symbol_instances_in_one_undo_step() { + for id in TYPES { + let mut s = session(); + for (what, target) in sources(&mut s) { + select(&mut s, &[target]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_or_else(|e| panic!("{id} on a {what}: {e}")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{id} on a {what}"); + let n = s.doc().unwrap().doc.node(target).unwrap().clone(); + assert_eq!(n.appearance.effects.iter().filter(|e| e.id == id).count(), 1, "{id} on a {what}"); + // The object stays what it is, and the effect draws its solid. + assert!(!matches!(n.kind, NodeKind::Path { .. }), "{id} on a {what}"); + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::revolve_art_in(Some(d), &n).or_else(|| vectorcraft_render::effects::solid_art_in(Some(d), &n)); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws its solid"); + // Edited through the dialog's command too. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"rotationY": 20.0}})) + .unwrap_or_else(|e| panic!("{id} on a {what}: {e}")); + } + } +} + +#[test] +fn a_group_holding_an_image_refuses_every_three_d_type_and_names_image_trace() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0); + let image = s + .edit("Place", |d, _| { + let id = d.alloc_id(); + let img = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (parent, index, _) = d.position(a).unwrap(); + d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?; + Ok(id) + }) + .unwrap(); + select(&mut s, &[a, image]); + s.execute("object.group", &json!({})).unwrap(); + for target in [image, s.doc().unwrap().selection.objects[0]] { + select(&mut s, &[target]); + for id in TYPES { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_err().to_string(); + assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}"); + assert_eq!(s.doc().unwrap().doc, before, "{id}: nothing changed"); + } + } +} diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9bc9ba756..9d5e9c0d1 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -445,7 +445,8 @@ fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option> { if !effects::has_revolve(n) && !effects::has_solid(n) { return None; } - let art = effects::revolve_art(n).or_else(|| effects::solid_art(n))?; + let doc = Some(symbols.document()); + let art = effects::revolve_art_in(doc, n).or_else(|| effects::solid_art_in(doc, n))?; Some(three_d_art_bounds(symbols, &art)) } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 75143f9a0..e9aa8486d 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -588,7 +588,7 @@ impl Renderer { let art = match effects::crop_marks_art(a) .or_else(|| crate::addons::halftone::art(doc, a)) .or_else(|| { - let art = effects::revolve_art(a).or_else(|| effects::solid_art(a)); + let art = effects::revolve_art_in(Some(doc), a).or_else(|| effects::solid_art_in(Some(doc), a)); self.stats.three_d_built += usize::from(art.is_some()); three_d |= art.is_some(); art diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 5c4474b22..659156fcc 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -612,6 +612,44 @@ 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. +#[test] +fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() { + use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind, Symbol}; + let square = |x: f64, y: f64| { + Arc::new(Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0), + )) + }; + let mut d = Document::new(200.0, 200.0); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: square(0.0, 0.0) }); + let mut clip = (*square(30.0, 30.0)).clone(); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }); + 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 }, + ]; + for 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 = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }]; + let solid_doc = doc_of(&mut d.clone(), n); + 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); + } +} + fn doc_of(d: &mut Document, n: Node) -> Document { let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f3d5dc90..a6b868dde 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4979,7 +4979,8 @@ lighting Light Светлина Bevel both sides Фасет от двете страни Presets Предварителни настройки @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Един обект може да има само един 3D ефект. Редактирайте параметрите на съществуващия ефект или го премахнете предварително. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} в момента изисква път или съставен път. Първо преобразувайте текста, изображенията и групите в пътища. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} в момента изисква път, съставен път, текст, група, преливане или екземпляр на символ. Първо разширете другите обекти. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} не може да създаде тяло от изображение: първо го трасирайте с Обект › Трасиране на изображение › Създаване. @msg {label} requires a shape with at least two anchors {label} изисква форма с поне две опорни точки @msg The shape is too complex for a 3D effect Формата е твърде сложна за 3D ефект @msg The 3D surface is too complex; simplify the shape 3D повърхността е твърде сложна; опростете формата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b432ce9dd..2d75b680e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5488,7 +5488,8 @@ mark-style Japanese Japanski Bevel both sides Zakošenje s obje strane Presets Unaprijed postavljene vrijednosti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekat prima jedan 3D efekat. Uredite opcije postojećeg efekta ili ga prvo uklonite. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} trenutno zahtijeva putanju ili složenu putanju. Prvo pretvorite tekst, slike i grupe u putanje. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} trenutno zahtijeva putanju, složenu putanju, tekst, grupu, stapanje ili instancu simbola. Prvo proširite ostale objekte. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne može napraviti tijelo od slike: prvo je pratite pomoću Objekat › Praćenje slike › Napravi. @msg {label} requires a shape with at least two anchors {label} zahtijeva oblik s najmanje dva sidra @msg The shape is too complex for a 3D effect Oblik je previše složen za 3D efekat @msg The 3D surface is too complex; simplify the shape 3D površina je previše složena; pojednostavite oblik diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b00834c4e..e606a329d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5917,7 +5917,8 @@ digits Malayalam Malaiàlam @msg {key} must be a finite coordinate {key} ha de ser una coordenada finita @msg {key} must be an array of non-negative integer object ids {key} ha de ser una matriu d’identificadors d’objecte enters no negatius @msg {key} must be Unix seconds (an integer) or null {key} ha de ser segons Unix (un enter) o null -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} actualment requereix un traçat, un traçat compost, text o un grup. Converteix primer les imatges i altres objectes en traçats. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} actualment requereix un traçat, un traçat compost, text, un grup, una fusió o una instància de símbol. Expandeix primer els altres objectes. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no pot crear un sòlid a partir d’una imatge: traça-la primer amb Objecte › Traçat d’imatge › Crea. @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots. @msg {label} produced an empty result {label} ha produït un resultat buit @msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bfa4fc62e..81d9aca94 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5587,7 +5587,8 @@ mark-style Japanese Japonské Bevel both sides Zkosení na obou stranách Presets Předvolby @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekt může mít jen jeden 3D efekt. Upravte volby stávajícího efektu, nebo jej nejprve odstraňte. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} zatím vyžaduje cestu, složenou cestu, text nebo skupinu. Obrazy a další objekty nejprve převeďte na cesty. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} zatím vyžaduje cestu, složenou cestu, text, skupinu, prolnutí nebo instanci symbolu. Ostatní objekty nejprve rozložte. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nedokáže vytvořit těleso z obrazu: nejprve jej vektorizujte příkazem Objekt › Vektorizace obrazu › Vytvořit. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ceb4dc632..5e81fa91d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5141,7 +5141,8 @@ mark-style Japanese Japansk Bevel both sides Facet på begge sider Presets Forudindstillinger @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Et objekt kan kun have én 3D-effekt. Redigér den eksisterende effekts indstillinger, eller fjern den først. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kræver i øjeblikket en sti eller en sammensat sti. Konvertér tekst, billeder og grupper til stier først. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} kræver i øjeblikket en sti, en sammensat sti, tekst, en gruppe, en blanding eller en symbolforekomst. Udvid andre objekter først. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan ikke bygge et legeme ud fra et billede: spor det først med Objekt › Billedsporing › Opret. @msg {label} requires a shape with at least two anchors {label} kræver en form med mindst to ankerpunkter @msg The shape is too complex for a 3D effect Formen er for kompleks til en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-fladen er for kompleks; forenkl formen diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a8a2e3e05..976ea71df 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5154,7 +5154,8 @@ lighting Light Licht Bevel both sides Abgeflachte Kante beidseitig Presets Vorgaben @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ein Objekt kann nur einen 3D-Effekt haben. Bearbeiten Sie die Optionen des vorhandenen Effekts oder entfernen Sie ihn zuerst. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text oder eine Gruppe. Wandeln Sie Bilder und andere Objekte zuerst in Pfade um. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text, eine Gruppe, eine Angleichung oder eine Symbolinstanz. Wandeln Sie andere Objekte zuerst um. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kann aus einem Bild keinen Körper erstellen: Zeichnen Sie es zuerst mit Objekt › Bildnachzeichner › Erstellen nach. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 30e5675bb..6be9d6712 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4948,7 +4948,8 @@ digits Malayalam Μαλαγιάλαμ Bevel Inside Εσωτερική λοξότμηση Bevel both sides Λοξότμηση και των δύο πλευρών @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ένα αντικείμενο δέχεται ένα εφέ 3D. Επεξεργαστείτε τις επιλογές του υπάρχοντος εφέ ή αφαιρέστε το πρώτα. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο, ομάδα, ανάμειξη ή στιγμιότυπο συμβόλου. Επεκτείνετε πρώτα τα άλλα αντικείμενα. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. Το {label} δεν μπορεί να δημιουργήσει στερεό από εικόνα: ανιχνεύστε την πρώτα με Αντικείμενο › Ανίχνευση περιγραμμάτων εικόνας › Κατασκευαστής. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c12636f9..91a43f4f8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5528,7 +5528,8 @@ mark-style Japanese Japonesas Bevel both sides Bisel en ambos lados Presets Ajustes preestablecidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objeto admite un solo efecto 3D. Edite las opciones del efecto existente o quítelo primero. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} requiere por ahora un trazado, un trazado compuesto, texto o un grupo. Convierta primero las imágenes y otros objetos en trazados. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} requiere por ahora un trazado, un trazado compuesto, texto, un grupo, una fusión o una instancia de símbolo. Expanda primero los demás objetos. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no puede crear un sólido a partir de una imagen: cálquela primero con Objeto › Calco de imagen › Crear. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e9bb13d60..e4fe506e6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5471,7 +5471,8 @@ mark-style Japanese Japanilainen Bevel both sides Viiste molemmin puolin Presets Esiasetukset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objektilla voi olla yksi 3D-tehoste. Muokkaa olemassa olevan tehosteen asetuksia tai poista se ensin. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin tai ryhmän. Muunna kuvat ja muut objektit ensin poluiksi. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin, ryhmän, sulautuksen tai symbolin ilmentymän. Laajenna muut objektit ensin. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ei voi luoda kappaletta kuvasta: jäljitä se ensin komennolla Objekti › Kuvan jäljitys › Luo. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ddbcf4d19..83f9f48e7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5158,7 +5158,8 @@ lighting Light Lumière Bevel both sides Biseau des deux côtés Presets Paramètres prédéfinis @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objet n'accepte qu'un seul effet 3D. Modifiez les options de l'effet existant ou supprimez-le d'abord. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte ou un groupe. Convertissez d'abord les images et les autres objets en tracés. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte, un groupe, un dégradé de formes ou une instance de symbole. Décomposez d'abord les autres objets. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne peut pas créer de solide à partir d'une image : vectorisez-la d'abord avec Objet › Vectorisation de l’image › Créer. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 5971e539f..c6d967e66 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5399,7 +5399,8 @@ antiAlias Sharp शार्प @msg {key} must name a registered panel {key} एक पंजीकृत पैनल का नाम होना चाहिए @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} फ़ाइलों में पारदर्शिता नहीं होती और इस फ़ाइल में {what} होगा: पहले इसे फ़्लैट करें (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें) -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें। +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप, समूह, ब्लेंड या सिंबल इंस्टेंस आवश्यक है। पहले अन्य ऑब्जेक्ट को एक्सपैंड करें। +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} इमेज से ठोस नहीं बना सकता: पहले ऑब्जेक्ट › इमेज ट्रेस › बनाएँ से उसे ट्रेस करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 578339347..b074101a4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5570,7 +5570,8 @@ imageTrace Outline Körvonal Bevel both sides Élletörés mindkét oldalon Presets Készletek @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Egy objektumnak csak egy 3D effektusa lehet. Szerkessze a meglévő effektus beállításait, vagy előbb távolítsa el. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} egyelőre görbét, összetett görbét, szöveget vagy csoportot igényel. Előbb alakítsa görbévé a képeket és az egyéb objektumokat. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} egyelőre görbét, összetett görbét, szöveget, csoportot, összemosást vagy szimbólumpéldányt igényel. Előbb bontsa ki az egyéb objektumokat. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} képből nem tud testet létrehozni: előbb vektorizálja az Objektum › Képvektorizálás › Létrehozás paranccsal. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 05d08d4bd..86d8ffff4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5141,7 +5141,8 @@ mark-style Japanese Jepang Bevel both sides Bevel di kedua sisi Presets Preset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Sebuah objek hanya menerima satu efek 3D. Edit opsi efek yang sudah ada, atau hapus dulu efek tersebut. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} saat ini memerlukan jalur atau jalur majemuk. Konversikan dulu teks, gambar, dan grup menjadi jalur. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} saat ini memerlukan jalur, jalur majemuk, teks, grup, blend, atau instans simbol. Perluas dulu objek lainnya. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} tidak dapat membuat benda padat dari gambar: lacak dulu dengan Objek › Image Trace › Buat. @msg {label} requires a shape with at least two anchors {label} memerlukan bentuk dengan setidaknya dua jangkar @msg The shape is too complex for a 3D effect Bentuknya terlalu kompleks untuk efek 3D @msg The 3D surface is too complex; simplify the shape Permukaan 3D terlalu kompleks; sederhanakan bentuknya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f225c4d5a..c063af4da 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5196,7 +5196,8 @@ lighting Light Luce Bevel both sides Smusso su entrambi i lati Presets Predefiniti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un oggetto può avere un solo effetto 3D. Modificate le opzioni dell'effetto esistente oppure rimuovetelo prima. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} richiede per ora un tracciato, un tracciato composto, testo o un gruppo. Convertite prima immagini e altri oggetti in tracciati. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} richiede per ora un tracciato, un tracciato composto, testo, un gruppo, una fusione o un'istanza di simbolo. Espandete prima gli altri oggetti. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} non può creare un solido da un'immagine: ricalcatela prima con Oggetto › Ricalco immagine › Crea. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d176d839..f4fa2e26f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5238,7 +5238,8 @@ lighting Light ライト Bevel both sides 両面にベベル Presets プリセット @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 1 つのオブジェクトに適用できる 3D 効果は 1 つです。既存の効果のオプションを編集するか、先に削除してください。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} には現在、パス、複合パス、テキスト、グループ、ブレンド、またはシンボルインスタンスが必要です。その他のオブジェクトは先に拡張してください。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} は画像から立体を作成できません。先に「オブジェクト › 画像トレース › 作成」でトレースしてください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2595036c2..dfd1766a0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4959,7 +4959,8 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი, ჯგუფი, გადასვლა ან სიმბოლოს ეგზემპლარი. ჯერ გაშალეთ სხვა ობიექტები. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0cc31464f..0a16765e7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5157,7 +5157,8 @@ lighting Light 광원 Bevel both sides 양면 베벨 Presets 사전 설정 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 오브젝트에는 3D 효과를 하나만 적용할 수 있습니다. 기존 효과의 옵션을 편집하거나 먼저 제거하십시오. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}에는 현재 패스, 컴파운드 패스, 문자, 그룹, 블렌드 또는 심볼 인스턴스가 필요합니다. 기타 개체를 먼저 확장하십시오. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}은(는) 이미지로 입체를 만들 수 없습니다. 먼저 개체 › 이미지 추적 › 만들기로 추적하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d38e11ef7..97c276733 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5153,7 +5153,8 @@ mark-style Japanese Japans Bevel both sides Afschuining aan beide zijden Presets Voorinstellingen @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Een object kan maar één 3D-effect hebben. Pas de opties van het bestaande effect aan of verwijder het eerst. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vereist momenteel een pad of samengesteld pad. Zet tekst, afbeeldingen en groepen eerst om naar paden. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} vereist momenteel een pad, samengesteld pad, tekst, groep, vermenging of symboolinstantie. Breid andere objecten eerst uit. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan geen massief object maken van een afbeelding: traceer deze eerst met Object › Afbeelding traceren › Maken. @msg {label} requires a shape with at least two anchors {label} vereist een vorm met minstens twee ankerpunten @msg The shape is too complex for a 3D effect De vorm is te complex voor een 3D-effect @msg The 3D surface is too complex; simplify the shape Het 3D-oppervlak is te complex; vereenvoudig de vorm diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f8c4a23f7..19871740e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5417,7 +5417,8 @@ mark-style Japanese Japońskie Bevel both sides Faza po obu stronach Presets Ustawienia @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Obiekt może mieć jeden efekt 3D. Edytuj opcje istniejącego efektu lub najpierw go usuń. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu lub grupy. Najpierw przekonwertuj obrazy i inne obiekty na ścieżki. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu, grupy, przejścia lub wystąpienia symbolu. Najpierw rozszerz inne obiekty. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nie może utworzyć bryły z obrazu: najpierw obrysuj go poleceniem Obiekt › Obrys obrazu › Utwórz. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 30a61dae3..1897df943 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5532,7 +5532,8 @@ mark-style Japanese Japonesas Bevel both sides Chanfro nos Dois Lados Presets Predefinidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Um objeto aceita apenas um efeito 3D. Edite as opções do efeito existente ou remova-o primeiro. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} exige, por enquanto, um caminho, caminho composto, texto ou grupo. Converta antes imagens e outros objetos em caminhos. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} exige, por enquanto, um caminho, caminho composto, texto, grupo, mesclagem ou instância de símbolo. Expanda antes os outros objetos. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} não pode criar um sólido a partir de uma imagem: rastreie-a antes com Objeto › Rastreamento de Imagem › Criar. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dd954cea0..94cf00653 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5190,7 +5190,8 @@ lighting Light Свет Bevel both sides Фаска с обеих сторон Presets Наборы @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. У объекта может быть только один 3D-эффект. Измените параметры существующего эффекта или сначала удалите его. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: пока требуется контур, составной контур, текст, группа, переход или экземпляр символа. Сначала разберите другие объекты. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: из изображения нельзя построить тело. Сначала трассируйте его командой Объект › Трассировка изображения › Создать. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d83100b8c..af48a12ff 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5062,7 +5062,8 @@ lighting Light Ljus Bevel both sides Fasning på båda sidor Presets Förinställningar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ett objekt kan bara ha en 3D-effekt. Redigera alternativen för den befintliga effekten eller ta bort den först. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} kräver för närvarande en bana, sammansatt bana, text eller grupp. Konvertera bilder och andra objekt till banor först. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} kräver för närvarande en bana, sammansatt bana, text, grupp, blandning eller symbolinstans. Expandera andra objekt först. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan inte skapa en solid av en bild: kalkera den först med Objekt › Bildkalkering › Skapa. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ba54e4058..c25c94832 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4858,7 +4858,8 @@ lighting Light Işık Bevel both sides Her iki tarafa da eğim verin Presets Ön ayarlar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} şu anda bir yol, bileşik yol, yazı veya grup gerektiriyor. Önce görüntüleri ve diğer nesneleri yollara dönüştürün. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} şu anda bir yol, bileşik yol, yazı, grup, karışım veya sembol örneği gerektiriyor. Önce diğer nesneleri genişletin. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} bir görüntüden katı cisim oluşturamaz: önce Nesne › Görüntü İzleme › Yap ile izleyin. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 056bf92ec..41c37ec4c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5176,7 +5176,8 @@ lighting Light Світло Bevel both sides Фаска з обох боків Presets Набори @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Об'єкт може мати лише один 3D-ефект. Змініть параметри наявного ефекту або спершу вилучіть його. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: поки що потрібен контур, складений контур, текст, група, перехід або екземпляр символу. Спершу розберіть інші об'єкти. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: із зображення не можна побудувати тіло. Спершу трасуйте його командою Об’єкт › Трасування зображення › Створити. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 624a2dbff..2774278f9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5176,7 +5176,8 @@ theme Appearance Giao diện @msg {C}: no effect at index {from} {C}: không có hiệu ứng tại chỉ mục {from} @msg {depth}-bit PSD files in this colour mode can't be read tệp PSD {depth}-bit ở chế độ màu này không thể đọc được @msg {key} must be a finite coordinate {key} phải là tọa độ hữu hạn -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản hoặc nhóm. Hãy chuyển hình ảnh và các đối tượng khác thành đường dẫn trước. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản, nhóm, hòa trộn hoặc thực thể ký hiệu. Hãy mở rộng các đối tượng khác trước. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} không thể tạo khối từ hình ảnh: hãy vector hóa ảnh trước bằng Đối tượng › Vector hóa ảnh › Tạo. @msg {label} produced an empty result {label} tạo ra kết quả trống @msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo @msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 501819665..a437d2f19 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5457,7 +5457,8 @@ lighting Light 光源 Bevel both sides 双面斜角 Presets 预设 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一个对象只能应用一种 3D 效果。请编辑现有效果的选项,或先将其移去。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}目前需要路径、复合路径、文字、编组、混合或符号实例。请先扩展其他对象。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}无法从图像生成立体:请先使用“对象 › 图像描摹 › 创建”进行描摹。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb983db79..65e1a99d3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5283,7 +5283,8 @@ lighting Light 光源 Bevel both sides 雙面斜角 Presets 預設集 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一個物件只能套用一種 3D 效果。請編輯現有效果的選項,或先將其移除。 -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。 +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}目前需要路徑、複合路徑、文字、群組、漸變或符號範例。請先展開其他物件。 +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}無法從影像建立立體:請先使用「物件 › 影像描圖 › 製作」描圖。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 diff --git a/docs/mcp.md b/docs/mcp.md index 4bf2e6c2b..aa248752f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2421,10 +2421,10 @@ default) white ones knock out instead; `"discard"` writes none. ## Revolve (initial 3D effect) `effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path, -compound path, type, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +compound path, type, clipping group, blend, symbol instance, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. -Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. +Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A clipping group sweeps its art inside the clipping path, a blend its keys and steps, and a symbol instance its symbol's art where the instance places it, alone or in a group ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)). An image, alone or in a group, is refused with "trace it first with Object › Image Trace › Make"; a group holding another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export cuts each sampled piece of a closed contour where it crosses another closed contour, adding a profile point at each cut, and tests the middle of each part against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), @@ -2500,6 +2500,11 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and `effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. On a group or layer the 3D effect goes on the group itself: `effect.apply` and `effect.setParams` refuse one on its fill or stroke. +Every 3D type, `threeD.classicExtrude` and `threeD.revolve` included, also applies to a clipping group, a blend or a +symbol instance, alone or in a group, and builds from the art it shows, the same way for the canvas, exports and +`assets.add3D` ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)); like a group, it takes the +effect on the object itself, not on a fill or stroke. An image, alone or in a group, is refused with "trace it first +with Object › Image Trace › Make", and nothing changes. `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` diff --git a/docs/revolve.md b/docs/revolve.md index 6ecd4957e..18be779d2 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,7 +2,7 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, clipping group, blend, symbol instance, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. @@ -96,7 +96,7 @@ with the standard command. - Lighting offers Standard, Diffuse, Top Left and Right, using the [shared original rigs](three-d.md#presets). Other effective settings show Custom; changing the view does not change the recognized lighting preset. -- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. +- Paths, compound paths, type, clipping groups, blends, symbol instances, and groups or layers of them. A clipping group, a blend or a symbol instance sweeps the art it shows about one axis, as described in [three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances). Images must be traced first with Object › Image Trace › Make, and other live objects expanded. - Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 826108b5f..e4884447f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -551,6 +551,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. +- 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 0e9901061..f94b17e38 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -4,7 +4,8 @@ The other three 3D types of **Effect › 3D and Materials** next to [Revolve](re live effect: the source path stays editable, the effect draws shaded vector faces, and SVG/PDF export, native saves and **Object › Expand Appearance** use the same evaluator. -Select a path or compound path, then choose **Effect › 3D and Materials › Extrude & Bevel…**, **Inflate…** or +Select a path, compound path, type, clipping group, blend, symbol instance, or a group or layer of them, then choose +**Effect › 3D and Materials › Extrude & Bevel…**, **Inflate…** or **Rotate…**. The options dialog opens beside the artboard with tabs: - **Object** (Extrude & Bevel and Inflate): the shape's own options, below. @@ -122,8 +123,31 @@ them all. The members stay editable: moving or editing one reshapes the solids. 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 on its own, the backmost member's first. -- Hidden members and guides are left out. A group holding an image, a symbol instance, a clipping group or another - live object is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. +- Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a + repeat) is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. + +## Clipping groups, blends and symbol instances + +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve) also applies to a clipping +group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to +objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), +that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), +and that a blend is treated as one object ([Blending objects](https://helpx.adobe.com/illustrator/using/blending-objects.html)). +How the solid is built from each is VectorCraft's own rule: it is built from what the object shows, as a group's +solids are ([Groups](#groups)), turned about one shared axis. + +- A clipping group (or a layer with a clipping mask) builds from its art inside the clipping path: each shape's fill + area cut to the clip, and each stroke's outline cut to the clip and filled in the stroke's paint. The clipping path + draws no solid. A clipping group inside another keeps what both clips show. A clip that shows none of the art + leaves nothing to build, and the effect is refused. +- A blend builds from its key objects and its steps, each in its own paint, in their order. +- 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. +- 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)). + A hidden image in a group is left out like any hidden member. ## Rotate @@ -231,9 +255,10 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. drawn in pieces about as long as a wall slice is wide (fewer near the limit of 32,768 facets). The piece drawn on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. -- Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, - clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about - one axis ([revolve.md](revolve.md)). One 3D effect per object. +- Paths, compound paths, type, clipping groups, blends, symbol instances and groups or layers of them (see + [Groups](#groups) and [Clipping groups, blends and symbol instances](#clipping-groups-blends-and-symbol-instances)); + images have to be traced first, and other live objects expanded. Revolve on a group sweeps its members about one + axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, From bad4b986f5c54dd6e63e71d9ffa71924c3eb180b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 00:29:58 -0800 Subject: [PATCH 02/10] 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. --- crates/effects/src/revolve.rs | 32 +++++ crates/effects/src/solids.rs | 8 +- crates/effects/src/solids/source.rs | 149 +++++++++++++++++---- crates/effects/src/solids/tests_sources.rs | 132 ++++++++++++++++++ crates/render/src/fx/tests.rs | 68 ++++++++-- docs/three-d.md | 7 +- 6 files changed, 360 insertions(+), 36 deletions(-) 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)). From cca1977a64634442821115f753d8252c69187ea5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 00:36:07 -0800 Subject: [PATCH 03/10] 3D: clipped groups keep their members' transparency and count their pieces - When a group's geometry effects reshape its members inside a clipping group, each member's piece goes back into groups with the opacity and blending mode of the groups it was in. - A clipped shape's pieces (its fills and each stroke) are counted against the 4,096-object and work limits before they are cut. --- crates/effects/src/solids/source.rs | 55 ++++++++++++++++++---- crates/effects/src/solids/tests_sources.rs | 21 +++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index bdc6e1085..4103a453c 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -11,7 +11,7 @@ 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, members, profile, region, reshape_members, solid_paints}; +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. @@ -215,13 +215,13 @@ impl Resolver<'_> { } let mut shapes = members(n, self.label)?; reshape_members(n, &n.appearance.effects, &mut shapes); + let mut cut = vec![]; 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)); + 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, .. }) { @@ -235,17 +235,24 @@ impl Resolver<'_> { 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. + let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + // Each cut makes a piece, counted before it is made: the shape was counted once already. + let made = usize::from(top_fill.is_some()) + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + self.spend(made)?; + self.leaves = self.leaves.saturating_add(made.saturating_sub(1)); + if self.leaves > MAX_MEMBERS { + return Err(self.too_many()); + } let path = geometry(&path, &n.appearance.effects, &GeomContext::of(n)); let cut = |area: &PathData, rule: FillRule| -> Result, String> { let inside = po::try_boolean(area, rule, &clip.0, clip.1, po::BoolOp::Intersect, po::DEFAULT_PRECISION).map_err(|_| self.too_complex())?; Ok(inside.bounds().is_some().then_some(inside)) }; - let items = solid_paints(n); let fills: Vec = 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 { @@ -277,6 +284,36 @@ 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 and blending mode: consecutive pieces sharing the scope at `level` go into +/// one group, so its transparency applies to them together. `level` stays below the nesting the +/// pieces came from ([`nested_past`]). +fn nest(n: &Node, pieces: Vec<(Vec, Node)>, level: usize) -> Vec> { + let mut out = vec![]; + let mut run: Vec<(Vec, Node)> = vec![]; + let mut key = None; + let close = |run: &mut Vec<(Vec, Node)>, scope: Option, out: &mut Vec>| { + let Some(scope) = scope else { return }; + let mut g = Node::group(n.id, nest(n, std::mem::take(run), level + 1)); + g.opacity = scope.opacity; + g.blend = scope.blend; + out.push(Arc::new(g)); + }; + for (scopes, piece) in pieces { + let here = scopes.get(level).copied(); + if here.map(|s| s.key) != key.map(|s: Scope| s.key) { + close(&mut run, key, &mut out); + key = here; + } + match here { + Some(_) => run.push((scopes, piece)), + None => out.push(Arc::new(piece)), + } + } + close(&mut run, key, &mut out); + out +} + /// 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)]; diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index b8063b2c5..01840c7e4 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -255,6 +255,14 @@ fn nested_symbol_instances_that_repeat_hidden_art_are_refused_past_the_work_limi assert!(art(&d, &n, id).is_none(), "{id}"); assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}"); } + // A clipped shape with more strokes than a 3D effect takes objects is refused before its + // strokes are cut into pieces. + let mut stroked = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); + let strokes = (0..5000).map(|_| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0))).collect(); + stroked.appearance = Appearance { items: strokes, ..Default::default() }.into(); + let n = with(clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), stroked), ROTATE, front()); + let err = validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).unwrap_err(); + assert!(err.contains("takes at most 4096 objects") && !err.contains("on a group"), "{err}"); // 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)); @@ -344,6 +352,19 @@ fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() 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:?}"); + // A member's opacity and blending mode stay on it when its group's effects reshape it in a clip. + let mut multiplied = left(); + multiplied.opacity = 0.5; + multiplied.blend = BlendMode::Multiply; + let n = clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(multiplied), Arc::new(left())]))); + let resolved = resolve(Some(&d), &n, "").unwrap().into_owned(); + let mut found = vec![]; + resolved.walk(&mut |c| { + if c.blend == BlendMode::Multiply { + found.push((c.opacity, c.children().map_or(0, Vec::len))); + } + }); + assert_eq!(found, vec![(0.5, 1)], "one Multiply group at 50% holding the one member's piece"); // 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(); From d3055e264eece79561080c41777744c762c34d7e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 00:44:03 -0800 Subject: [PATCH 04/10] 3D: an image a clipping group hides wholly does not refuse its 3D effect An image in a clipping group whose bounds lie wholly outside the clipping path's bounds draws nothing, so a 3D effect leaves it out instead of refusing it. An image with effects, fills or strokes of its own, or one in a group with effects inside the clipping group, is still refused, since those can draw it inside the clip. --- crates/effects/src/solids/source.rs | 43 +++++++++++++++++++--- crates/effects/src/solids/tests_sources.rs | 37 +++++++++++++++++-- docs/three-d.md | 4 +- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 4103a453c..933abecff 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind}; -use vectorcraft_geom::{FillRule, PathData}; +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}; @@ -57,7 +57,7 @@ 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, work: 0 }; + let mut r = Resolver { doc, label, leaves: 0, work: 0, shown: None }; let children = r.content(n, 0); #[cfg(test)] WORK.with(|w| w.set(r.work)); @@ -79,6 +79,8 @@ struct Resolver<'a> { leaves: usize, /// The objects visited or made so far, counted against [`MAX_WORK`]. work: usize, + /// Inside clipping groups, the box of what their clipping paths show together. + shown: Option, } impl Resolver<'_> { @@ -94,6 +96,18 @@ impl Resolver<'_> { Ok(()) } + /// `f` run on the art of `c`. The effects of an object or group can move its art into a clip, + /// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]). + fn inside(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result) -> Result { + let outer = self.shown; + if has_effects(c) { + self.shown = None; + } + let out = f(self); + self.shown = outer; + out + } + /// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one, /// shows, as the members of a plain group. fn content(&mut self, n: &Node, depth: usize) -> Result>, String> { @@ -122,7 +136,13 @@ impl Resolver<'_> { _ 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)?; + // 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)? { @@ -148,10 +168,18 @@ impl Resolver<'_> { 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.content(c, depth)?; + let kids = self.inside(c, |r| r.content(c, depth))?; out.push(Arc::new(with_effects(wrap(c, kids), c))); changed = true; continue; @@ -166,7 +194,7 @@ impl Resolver<'_> { out.push(c.clone()); continue; } - let (kids, inner) = self.list(kids(c), depth + 1)?; + 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; @@ -314,6 +342,11 @@ fn nest(n: &Node, pieces: Vec<(Vec, Node)>, level: usize) -> Vec bool { + n.appearance.effects.iter().any(|e| e.visible) +} + /// 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)]; diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 01840c7e4..d77e9f7c8 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -41,13 +41,18 @@ fn document() -> Document { d } -/// A clipping group: `art` inside a rectangular clipping path covering `clip`. -fn clip_group(id: u64, clip: Rect, art: Node) -> Node { - let mut path = Node::path(NodeId(id + 1), shapes::rectangle(clip), Appearance::default()); +/// A rectangular clipping path covering `clip`. +fn clipping_path(id: u64, clip: Rect) -> Node { + let mut path = Node::path(NodeId(id), shapes::rectangle(clip), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut path.kind { *clipping = true; } - Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(path), Arc::new(art)], clip: true }) + path +} + +/// A clipping group: `art` inside a rectangular clipping path covering `clip`. +fn clip_group(id: u64, clip: Rect, art: Node) -> Node { + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clipping_path(id + 1, clip)), Arc::new(art)], clip: true }) } fn blend(id: u64, steps: u32) -> Node { @@ -219,6 +224,30 @@ fn images_are_refused_alone_and_in_a_group_with_image_trace_named() { assert!(art(&d, &n, id).is_none(), "{id}"); assert!(three_d_meshes_in(Some(&d), &n).is_err_and(|e| e.contains("Image Trace")), "{id}"); } + // An image wholly outside the clip, alone or in a group, shows nothing and is left out; + // one the clip shows part of is refused. + let clip = Rect::new(100.0, 100.0, 150.0, 150.0); + let masked = |inside: bool| { + let mut img = image(63); + if let NodeKind::Image(i) = &mut img.kind { + i.xf = Affine::translate(if inside { (95.0, 95.0) } else { (300.0, 300.0) }); + } + img + }; + for img in [masked(false), Node::group(NodeId(64), vec![Arc::new(masked(false))])] { + let children = vec![Arc::new(clipping_path(61, clip)), Arc::new(rect(62, 100.0, 100.0, 200.0, 200.0, 0.0)), Arc::new(img)]; + let n = with(Node::new(NodeId(60), NodeKind::Group { children, clip: true }), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}"); + assert!(art(&d, &n, id).is_some(), "{id}"); + } + let partly = with(clip_group(60, clip, masked(true)), id, json!({})); + assert!(validate_three_d_in(Some(&d), &partly, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}"); + // An effect on the image, or on a group holding it, can move it into the clip: refused. + let moved = |n: Node| with(n, "distort.transform", json!({"moveH": -200.0, "moveV": -200.0})); + for img in [moved(masked(false)), moved(Node::group(NodeId(64), vec![Arc::new(masked(false))]))] { + let n = with(clip_group(60, clip, img), id, json!({})); + assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}"); + } // A hidden image is not drawn, so it builds nothing and refuses nothing. let mut hidden = image(53); hidden.visible = false; diff --git a/docs/three-d.md b/docs/three-d.md index b7bbde245..b07aabb3a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -152,7 +152,9 @@ solids are ([Groups](#groups)), turned about one shared axis. - 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)). - A hidden image in a group is left out like any hidden member. + A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds + lie wholly outside the clipping path's bounds, when neither the image nor a group holding it inside the clipping + group has effects. ## Rotate From 711b776fae127eaa448e304744e410c000332ccc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 00:59:35 -0800 Subject: [PATCH 05/10] 3D: deep clipping paths and deep groups in resolved art are refused A clipping path nested past the depth limit, and a group at the limit that holds further groups, are refused before they are walked, instead of being followed to any depth. The clipping path's check follows the members its outline is built from: the first member of a nested clipping group and the shown members of other groups. The group check skips hidden members and treats a compound path as one shape, as the solid builder does. --- crates/effects/src/solids/source.rs | 38 +++++++++++++--- crates/effects/src/solids/tests_sources.rs | 52 +++++++++++++++++++++- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 933abecff..0c69189cb 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -135,6 +135,9 @@ impl Resolver<'_> { } _ 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; @@ -185,10 +188,10 @@ impl Resolver<'_> { continue; } if is_container(c) { - // Past the depth limit a group is kept, and not walked further, only when nothing - // in it needs resolving. + // 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 needs(c) { + if nested_past(c, 1) || needs(c) { return Err(self.too_deep()); } out.push(c.clone()); @@ -347,20 +350,45 @@ fn has_effects(n: &Node) -> bool { n.appearance.effects.iter().any(|e| e.visible) } -/// Does `n` hold visible groups or layers nested more than `levels` deep? Walked without recursion. +/// 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 is_container(n) { + if level == 0 || is_container(n) { stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1))); } } false } +/// Does the clipping path `n` reach objects nested more than `levels` deep along the members that +/// [`Node::clip_shapes`] visits: the first member of a clipping group, the shown members of other +/// groups and layers (not a group's shaper), and every member of a live object. Walked without +/// recursion. +fn clip_nested_past(n: &Node, levels: usize) -> bool { + let mut stack = vec![(n, 0usize)]; + while let Some((n, level)) = stack.pop() { + if level > levels { + return true; + } + match &n.kind { + NodeKind::Group { children, clip: true } => stack.extend(children.first().map(|c| (&**c, level + 1))), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + let skip = usize::from(n.extras().shaper.is_some()); + stack.extend(children.iter().skip(skip).filter(|c| c.visible).map(|c| (&**c, level + 1))); + } + NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. } => {} + _ => stack.extend(kids(n).iter().map(|c| (&**c, level + 1))), + } + } + false +} + /// The members of `n`, none for an object without members. fn kids(n: &Node) -> &[Arc] { n.children().map_or(&[], Vec::as_slice) diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index d77e9f7c8..57828c1df 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -152,9 +152,53 @@ fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() { for i in 0..200 { deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]); } - let n = with(clip_group(10, clip, deep), ROTATE, front()); + let n = with(clip_group(10, clip, deep.clone()), ROTATE, front()); assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); assert!(solid_art_in(Some(&d), &n).is_none()); + // So is a clipping path nested past the limit, and a symbol's art nested past it. + let by_deep = + Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(deep.clone()), Arc::new(rect(12, 0.0, 0.0, 10.0, 10.0, 0.0))], clip: true }); + let mut deep_symbol = d.clone(); + deep_symbol.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + for n in [with(by_deep, ROTATE, front()), with(instance(30, "Deep", Affine::IDENTITY), ROTATE, front())] { + assert!(validate_three_d_in(Some(&deep_symbol), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep")), "{:?}", n.id); + assert!(three_d_meshes_in(Some(&deep_symbol), &n).is_err()); + } + // A compound path counts as one shape at the depth limit: a symbol's art nested as deep as a + // path may be also takes a compound path there. + let square = rect(80, 0.0, 0.0, 10.0, 10.0, 0.0); + let mut compound = + Node::new(NodeId(81), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance = square.appearance.clone(); + let mut seen = vec![]; + for levels in 60..70 { + let ok = |leaf: &Node| { + let mut art = leaf.clone(); + for i in 0..levels { + art = Node::group(NodeId(2000 + i), vec![Arc::new(art)]); + } + let mut doc = d.clone(); + doc.symbols.push(Symbol { dynamic: false, name: "Nested".into(), art: Arc::new(art) }); + validate_three_d_in(Some(&doc), &with(instance(30, "Nested", Affine::IDENTITY), ROTATE, front()), ROTATE, &json!({})).is_ok() + }; + let path_ok = ok(&square); + assert_eq!(ok(&compound), path_ok, "{levels} levels"); + seen.push(path_ok); + } + assert!(seen.contains(&true) && seen.contains(&false), "the levels cross the limit: {seen:?}"); + // A clipping group used as the clipping path clips by its first member only, so deep art it + // clips is not walked for the clip. + let mask = Node::new( + NodeId(70), + NodeKind::Group { children: vec![Arc::new(clipping_path(71, clip)), Arc::new(deep_symbol.symbols[1].art.as_ref().clone())], clip: true }, + ); + let by_deep_mask = with( + Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }), + ROTATE, + front(), + ); + assert_eq!(validate_three_d_in(Some(&d), &by_deep_mask, ROTATE, &json!({})), Ok(())); + assert!(close(bounds(&solid_art_in(Some(&d), &by_deep_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01)); // A clip that shows none of the art builds nothing, and says so. let empty = clip_group(10, Rect::new(500.0, 500.0, 550.0, 550.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0)); for id in TYPES { @@ -400,8 +444,12 @@ fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() 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()); + let n = with(clip_group(60, clip, moved(deep.clone())), ROTATE, front()); assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep"))); + // A hidden member nested past the limit is left out, as it is drawn, and does not refuse. + deep.visible = false; + let n = with(clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(deep), Arc::new(left())]))), ROTATE, front()); + assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(())); // 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; From ce1940888a6456dc006b46866ae6b85308450222 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:33:32 -0800 Subject: [PATCH 06/10] 3D: group tests check that clipping groups apply and images are refused Two engine tests still expected a clipping group to be refused. Each solid 3D effect (Extrude, Inflate, Rotate and Classic Extrude) on a group holding a clipping group now applies and draws a solid, with the members unchanged. Revolve on a clipping group, alone or in a group, now draws its surface. The refusal cases now use an image, alone or in a group, which is still refused with the Image Trace message and leaves the document unchanged. --- crates/engine/src/cmd/effectcmd/tests.rs | 87 ++++++++++++++++++------ 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 6604c99a2..e4c8d7945 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -130,7 +130,8 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { } /// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis; -/// the members stay editable, and a group holding what can't become a solid is refused. +/// the members stay editable. A group holding a clipping group builds from the art it shows, and a +/// group holding what can't become a solid is refused. #[test] fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { for effect in effects::SOLIDS { @@ -151,17 +152,59 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded"); } - // A clipping group in the group: refused, nothing changed. + // A clipping group in the group: each effect builds from the art it shows. + for effect in effects::SOLIDS { + let (mut s, _, g) = group_with_a_clipping_group(); + s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap_or_else(|e| panic!("{effect}: {e}")); + let n = node(&s, g); + assert_eq!(n.children().unwrap().len(), 2, "{effect}: the members stay as they were"); + let art = effects::solid_art_in(Some(&s.doc().unwrap().doc), &n); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{effect}: draws its solid"); + } + // An image in the group: refused, nothing changed. + let (mut s, _, g) = group_with_an_image(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Image Trace"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); +} + +/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session, +/// the clipping group and the group. +fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) { let (mut s, a) = session_with_rect(); let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); - let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + s.execute("select.set", &json!({"ids": [clip.0, c]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); - assert!(err.contains("clipping groups"), "{err}"); - assert!(node(&s, g).appearance.effects.is_empty()); + (s, clip, g) +} + +/// A group of a rectangle and an image: the session, the image and the group. +fn group_with_an_image() -> (Session, NodeId, NodeId) { + let (mut s, a) = session_with_rect(); + let image = s + .edit("Place", |d, _| { + let id = d.alloc_id(); + let img = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate((250.0, 250.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (parent, index, _) = d.position(a).unwrap(); + d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?; + Ok(id) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, image.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + (s, image, g) } /// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays @@ -339,21 +382,25 @@ fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { assert!(effects::has_revolve(&node(&s, g))); } -/// A group holding what can't be swept is refused as a whole, and a clipping group is named. +/// A group holding what can't be swept is refused as a whole, and an image is named. A clipping +/// group, alone or in a group, sweeps the art it shows. #[test] fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() { - let (mut s, a) = session_with_rect(); - let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); - let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); - let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string(); - assert!(err.contains("path, compound path, type or group"), "{err}"); - let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); - assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}"); - assert!(node(&s, g).appearance.effects.is_empty()); + let (mut s, image, g) = group_with_an_image(); + let before = s.doc().unwrap().doc.clone(); + for target in [image, g] { + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Revolve") && err.contains("Image Trace"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); + } + for clipping_group_itself in [true, false] { + let (mut s, clip, g) = group_with_a_clipping_group(); + let target = if clipping_group_itself { clip } else { g }; + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})) + .unwrap_or_else(|e| panic!("{clipping_group_itself}: {e}")); + let art = effects::revolve_art_in(Some(&s.doc().unwrap().doc), &node(&s, target)); + assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{clipping_group_itself}: draws its surface"); + } } /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is From d7845e70680ef4e3a2a732bd078de0fedf8c0297 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:59:52 -0800 Subject: [PATCH 07/10] 3D: Rotate keeps each clipped fill in its place among the strokes Inside a clipping group, the fills of a shape were cut into one piece in the top fill's place. Rotate turns every fill, so with a fill, a stroke and a fill, the lower fill was drawn above the stroke. With Rotate, each fill now becomes its own piece in its own place. Extrude & Bevel, Inflate and Revolve build from the top fill and keep one piece there, also when an object changes from Rotate to another 3D type. --- crates/effects/src/revolve.rs | 10 +++---- crates/effects/src/solids.rs | 4 +-- crates/effects/src/solids/source.rs | 35 +++++++++++++++++----- crates/effects/src/solids/tests_sources.rs | 19 +++++++++++- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b31f7ca71..a5004ab99 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -79,10 +79,10 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { } /// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group -/// revolve the art they show ([`resolve`]). +/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let n = &*resolve(doc, n, "Revolve")?; + let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); @@ -329,7 +329,7 @@ pub fn revolve_art(n: &Node) -> Option { } /// [`revolve_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone -/// or in a group, revolve the art they show ([`resolve`]). `None` too when that art can't be +/// or in a group, revolve the art they show ([`resolve_for`]). `None` too when that art can't be /// resolved. pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { art(doc, n, false) @@ -413,7 +413,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let n = &*resolve(doc, n, "Revolve").ok()?; + let n = &*resolve_for(doc, n, "Revolve", false).ok()?; if is_container(n) { return group_art(n, expand); } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 06461f8de..05d2da4e7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -18,7 +18,7 @@ mod classic; pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; -pub(crate) use source::resolve; +pub(crate) use source::{resolve, resolve_for}; pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; @@ -216,7 +216,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); - let n = &*resolve(doc, n, label)?; + let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 0c69189cb..9ab348f4a 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -52,12 +52,21 @@ fn needs(n: &Node) -> bool { /// 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. +/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut +/// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result, String> { + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + resolve_for(doc, n, label, every_fill) +} + +/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every +/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the +/// effect builds from the top fill, and the fills cut go together in the top fill's place. +pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result, String> { if !needs(n) { return Ok(Cow::Borrowed(n)); } - let mut r = Resolver { doc, label, leaves: 0, work: 0, shown: None }; + 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)); @@ -75,6 +84,8 @@ thread_local! { 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`]. @@ -268,10 +279,13 @@ impl Resolver<'_> { 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. + // 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 = usize::from(top_fill.is_some()) + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + 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 { @@ -284,12 +298,19 @@ impl Resolver<'_> { Ok(inside.bounds().is_some().then_some(inside)) }; let fills: Vec = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect(); + // Every fill paints the same area: it is cut once. + let mut fill_area: Option> = None; let mut pieces = vec![]; for (k, item) in items.iter().enumerate() { match item { - AppearanceItem::Fill(_) if Some(k) == top_fill => { - if let Some(area) = cut(&path, rule)? { - pieces.push(Arc::new(shape(n, area, FillRule::NonZero, fills.clone()))); + 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(_) => {} diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 57828c1df..ab97bdb51 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -146,7 +146,24 @@ fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() { let colors = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), EXTRUDE, json!({}))).unwrap().iter().map(|m| m.color).collect::>(); let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's"); - assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered)), plain); + assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered.clone())), plain); + // Rotate turns both fills and the stroke, each in its own place. + let turned = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), ROTATE, front())).unwrap().iter().map(|m| m.color).collect::>(); + let plain = turned(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); + assert_eq!(plain.len(), 3, "both fills' surfaces and the stroke's"); + let clipped = clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered); + assert_eq!(turned(&clipped), plain); + assert_eq!(validate_three_d_in(Some(&d), &clipped, ROTATE, &json!({})), Ok(())); + // Each fill Rotate turns counts against the limit on objects; another effect takes the top + // fill only, whatever 3D effect the object carries before it changes type. + let mut many = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0); + many.appearance = + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); MAX_MEMBERS + 1], ..Default::default() }.into(); + let rotated = with(clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), many), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &rotated, ROTATE, &json!({})).is_err_and(|e| e.contains("takes at most"))); + for id in [EXTRUDE, crate::REVOLVE] { + assert_eq!(validate_three_d_in(Some(&d), &rotated, id, &json!({})), Ok(()), "Rotate changed to {id}"); + } // Art nested past the limit inside a clipping group is refused, not followed to any depth. let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); for i in 0..200 { From 6e3f04032df27e9cc71c1577107ff421f3f16a6e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 11:48:39 -0800 Subject: [PATCH 08/10] 3D: the group refusal lists groups and clipping groups among what it builds from The message for a group member that can't become a solid now names groups and clipping groups too. docs/three-d.md states that an image left out of a clipping group also needs no visible fills or strokes of its own. --- crates/effects/src/solids.rs | 2 +- crates/effects/src/solids/tests.rs | 1 + docs/three-d.md | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 05d2da4e7..4ac4628b1 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -318,7 +318,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { _ => "other objects", }; return Err(format!( - "{label} on a group needs paths, compound paths, type, blends and symbol instances: release or expand its {what} first." + "{label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first." )); }; if w.out.len() >= MAX_MEMBERS { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 5d1227789..d4539e7c2 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -598,6 +598,7 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { } let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err(); assert!(err.contains("release or expand its other objects"), "{err}"); + assert!(err.contains("type, groups, clipping groups, blends and symbol instances"), "{err}"); assert!(solid_art(&g).is_none(), "drawn as the group it is"); } diff --git a/docs/three-d.md b/docs/three-d.md index b07aabb3a..9cb82b0c7 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -153,8 +153,8 @@ solids are ([Groups](#groups)), turned about one shared axis. - 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)). A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds - lie wholly outside the clipping path's bounds, when neither the image nor a group holding it inside the clipping - group has effects. + lie wholly outside the clipping path's bounds, when the image has no visible fills or strokes of its own and + neither the image nor a group holding it inside the clipping group has effects. ## Rotate From 667204e7c44ae1eac3c25b9c092fa77fdca92f56 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:03:34 -0800 Subject: [PATCH 09/10] 3D: dialog tests use an image as the object a 3D effect refuses Three Extrude & Bevel dialog tests used a clipping group as their example of an object a 3D effect refuses, and clipping groups now apply. Their helper now places an image, and they check for the image message ("can't build a solid from an image") in place of "requires a path". --- .../src/dialogs/tests_revolve_gizmo.rs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs index 75aecde46..46c912170 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -315,22 +315,18 @@ impl Harness { } } -fn clipping_group() -> Vec<(&'static str, Value)> { - vec![ - ("shape.ellipse", json!({"x":20,"y":20,"width":100,"height":100})), - ("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100})), - ("select.all", json!({})), - ("object.clippingMask.make", json!({})), - ] +fn an_image() -> Vec<(&'static str, Value)> { + let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); + vec![("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [150, 150]}))] } /// #6140: on an object a 3D effect can't take, the dialog says why in its own words, and OK, which /// could only fail, is off, where it used to look as if OK did nothing. #[test] fn a_refused_3d_effect_says_why_and_turns_ok_off() { - let mut h = Harness::extrude_dialog(&clipping_group(), true); + let mut h = Harness::extrude_dialog(&an_image(), true); assert!(!h.ok_is_on()); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.shows("effect.apply"), "the reason is written without the command's id"); let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; h.frame(vec![enter], Modifiers::NONE); @@ -341,9 +337,9 @@ fn a_refused_3d_effect_says_why_and_turns_ok_off() { /// and trying it leaves no preview open. #[test] fn a_refused_3d_effect_is_found_with_preview_off() { - let mut h = Harness::extrude_dialog(&clipping_group(), false); + let mut h = Harness::extrude_dialog(&an_image(), false); assert!(!h.ok_is_on()); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.app.session.in_interaction()); } @@ -351,7 +347,7 @@ fn a_refused_3d_effect_is_found_with_preview_off() { /// one would replace its preview and cancel it. #[test] fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() { - let mut h = Harness::extrude_dialog(&clipping_group(), false); + let mut h = Harness::extrude_dialog(&an_image(), false); h.app.session.begin_interaction("Drag").unwrap(); let d = h.app.ui.dialog.as_mut().unwrap(); d.fields.remove("__tried"); @@ -360,7 +356,7 @@ fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() { assert!(h.app.session.in_interaction(), "the other interaction goes on"); assert!(h.ok_is_on(), "nothing has tried the values yet"); h.app.session.cancel_interaction().unwrap(); - assert!(h.shows("requires a path")); + assert!(h.shows("can't build a solid from an image")); assert!(!h.ok_is_on()); assert!(!h.app.session.in_interaction()); } From 9fced9388e96f35137708b9365fd8fc4082c3f01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:18:08 -0800 Subject: [PATCH 10/10] 3D: a symbol holding too many objects is refused before its art is placed A 3D effect on a symbol instance placed a copy of the symbol's whole art before counting it against the effect's work limit, so a symbol far past the limit was copied in full on every check and redraw before it was refused. The symbol's objects are now counted first, and a symbol that would pass the limit is refused with the same message without being placed. --- crates/effects/src/solids/source.rs | 14 ++++++++++---- crates/effects/src/solids/tests_sources.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 9ab348f4a..090688c4f 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -99,14 +99,15 @@ impl Resolver<'_> { 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 - )); + return Err(self.too_much_work()); } Ok(()) } + fn too_much_work(&self) -> String { + format!("{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects", self.label) + } + /// `f` run on the art of `c`. The effects of an object or group can move its art into a clip, /// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]). fn inside(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result) -> Result { @@ -129,6 +130,11 @@ impl Resolver<'_> { self.spend(1)?; match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { + // Counted before the art is placed, so a symbol past the limit is never copied. + let held = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == *symbol)).map_or(0, |s| s.art.count_with_masks()); + if self.work.saturating_add(held) > MAX_WORK { + return Err(self.too_much_work()); + } 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") })?; diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index ab97bdb51..35e89e61f 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -363,6 +363,18 @@ fn nested_symbol_instances_that_repeat_hidden_art_are_refused_past_the_work_limi assert!(solid_art_in(Some(&d), &n).is_some()); } +#[test] +fn a_symbol_holding_more_objects_than_the_work_limit_is_refused_before_its_art_is_placed() { + let mut d = document(); + let members = (0..=source::MAX_WORK).map(|i| Arc::new(rect(0, i as f64, 0.0, i as f64 + 1.0, 1.0, 0.0))).collect(); + d.symbols.push(Symbol { dynamic: false, name: "Big".into(), art: Arc::new(Node::group(NodeId(0), members)) }); + let n = with(instance(30, "Big", Affine::IDENTITY), EXTRUDE, json!({})); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); +} + #[test] fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { let mut d = document();