Merge pull request #6418 from dotCooCoo/fix/3d-more-object-kinds

3D: effects build from clipping groups, blends and symbol instances
This commit is contained in:
BFlat
2026-11-11 05:13:37 +07:00
committed by GitHub
47 changed files with 1482 additions and 127 deletions
+3 -3
View File
@@ -124,7 +124,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
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<Node> {
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<Node> {
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 { .. }) {
+4 -3
View File
@@ -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;
+60 -6
View File
@@ -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_for, 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_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_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);
@@ -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<Node> {
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_for`]). `None` too when that art can't be
/// resolved.
pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
art(doc, n, false)
}
/// [`expand_revolve_art_in`] without a document.
#[cfg(test)]
pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
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<Node> {
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<Vec<crate::
Ok(out)
}
fn art(n: &Node, expand: bool) -> Option<Node> {
fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
if !has_revolve(n) {
return None;
}
let n = &*resolve_for(doc, n, "Revolve", false).ok()?;
if is_container(n) {
return group_art(n, expand);
}
@@ -1143,6 +1165,38 @@ mod tests {
assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget");
}
#[test]
fn the_art_of_symbol_instances_and_blends_shares_the_budget_of_paint_copies() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::{BlendSpacing, BlendSpec, Symbol};
let plain = member(1, 0.0);
let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let painted = |id: u64, x: f64| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut node = (*member(id, x)).clone();
node.appearance = Appearance::basic(paint, Paint::None, 0.0).into();
Arc::new(node)
};
let mut d = Document::new(500.0, 500.0);
d.symbols.push(Symbol { dynamic: false, name: "P".into(), art: painted(0, 0.0) });
let instance = |id: u64| Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: "P".into(), xf: Affine::IDENTITY }));
// One instance's art fits; two in the same place, each of whose surfaces fits alone, pass
// the budget together, as two paths would.
assert_eq!(validate_revolve_in(Some(&d), &group_with(json!({}), vec![instance(1)]), &json!({})), Ok(()));
let two = group_with(json!({}), vec![instance(1), instance(2)]);
let refused = validate_revolve_in(Some(&d), &two, &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
assert_eq!(revolve_art_in(Some(&d), &two), None, "past the budget the source draws");
// A blend's keys and step count together too.
let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() });
let blend = Arc::new(Node::new(NodeId(3), NodeKind::Blend { children: vec![painted(4, 0.0), painted(5, 0.0)], spec }));
let refused = validate_revolve_in(Some(&d), &group_with(json!({}), vec![blend]), &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
}
#[test]
fn the_fills_of_one_object_share_the_budget_of_paint_copies() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
+88 -27
View File
@@ -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, resolve_for};
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 = &*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"))?;
@@ -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,8 +278,10 @@ 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
/// effects and painted with its own fills and strokes. Like the effect on a group in the
/// 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, then those of each group it is in (on that group's members together), and painted
/// with its own fills and strokes. Like the effect on a group in the
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
/// than each about its own. An error names what the group holds that can't become a solid.
pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
@@ -287,18 +303,23 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend });
}
if is_container(c) {
let first = w.out.len();
walk(c, opacity, &scopes, w)?;
// The group's own geometry effects reshape its members together, as it is drawn.
if let Some(shapes) = w.out.get_mut(first..) {
reshape_members(c, &c.appearance.effects, shapes);
}
continue;
}
let Some((path, rule)) = profile(c) else {
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, groups, clipping groups, 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 +340,12 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, 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 +354,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 +569,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<Node> {
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<Node> {
if !has_solid(n) {
return None;
}
let n = &*resolve(doc, n, "").ok()?;
if is_container(n) {
return group_solid_art(n);
}
@@ -689,28 +731,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<Vec<SolidMesh>, 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<Vec<SolidMesh>, 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<Item = &'a Node>) -> Result<Vec<SolidMesh>, 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<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, 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<Item = &'a Node>) -> Result<Vec<SolidMesh>, 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<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, 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<Vec<SolidMesh>, String> {
fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, 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 +780,7 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidM
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
meshes_for(fx, &shapes, total, limit)?
} else {
let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?;
let (path, rule) = object_profile(n, label)?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
@@ -759,3 +817,6 @@ mod tests;
#[cfg(test)]
mod tests_mesh;
#[cfg(test)]
mod tests_sources;
+448
View File
@@ -0,0 +1,448 @@
//! The art a 3D effect builds its solids from when its object, or a member of its group, shows art
//! it does not hold as paths: a symbol instance its symbol's art placed by the instance, a blend its
//! key objects and steps, a clipping group (or a layer with a clipping mask) its art inside the
//! clipping path. Each becomes a plain group of that art, keeping its transparency, so the solids
//! build from what the object shows. Images build no solid: they are traced first.
use std::borrow::Cow;
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops as po;
use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints};
use crate::{GeomContext, is_geometry};
/// The deepest nesting of symbol instances, blends and clipping groups resolved for one 3D effect.
const MAX_DEPTH: usize = 64;
/// The most objects resolving one 3D effect's art may visit or make: every object listed (hidden
/// ones and guides included), every symbol instance, blend and clipping group resolved, every blend
/// step made and every object cut to a clipping path. A symbol that holds instances of another
/// repeats that symbol's art at every level, so art with few visible objects can still take this
/// many steps.
pub(super) const MAX_WORK: usize = 1 << 18;
/// The error for an image a 3D effect is asked to build from.
pub(crate) fn image_refusal(label: &str) -> String {
format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.")
}
/// Is `n` a symbol instance, a blend or a clipping group or layer?
pub(crate) fn resolves(n: &Node) -> bool {
matches!(n.kind, NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. }) || n.clips()
}
/// Is `n`, or a visible member of `n` at any depth of its groups and layers, one [`resolves`] takes?
fn needs(n: &Node) -> bool {
let mut stack = vec![n];
while let Some(n) = stack.pop() {
if resolves(n) {
return true;
}
if is_container(n) {
stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| &**c));
}
}
false
}
/// `n` as a 3D effect builds from it: unchanged when neither it nor a visible member of its groups
/// is a symbol instance, a blend or a clipping group, otherwise a group with `n`'s id, appearance
/// and transparency holding that art (a symbol instance's symbol art from `doc`). An error names
/// what can't be resolved: an image, a missing symbol, art nested or numbering past the limits, or
/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut
/// for the solid effect `n` carries ([`resolve_for`]).
pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> {
let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE);
resolve_for(doc, n, label, every_fill)
}
/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every
/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the
/// effect builds from the top fill, and the fills cut go together in the top fill's place.
pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result<Cow<'a, Node>, String> {
if !needs(n) {
return Ok(Cow::Borrowed(n));
}
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None };
let children = r.content(n, 0);
#[cfg(test)]
WORK.with(|w| w.set(r.work));
let mut out = n.clone();
out.kind = NodeKind::Group { children: children?, clip: false };
Ok(Cow::Owned(out))
}
#[cfg(test)]
thread_local! {
/// The objects the last [`resolve`] on this thread visited or made ([`Resolver::work`]).
pub(super) static WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
struct Resolver<'a> {
doc: Option<&'a Document>,
label: &'a str,
/// Does each fill a clipping path cuts become a piece of its own ([`resolve_for`])?
every_fill: bool,
/// The objects other than groups and layers kept so far, counted against [`MAX_MEMBERS`].
leaves: usize,
/// The objects visited or made so far, counted against [`MAX_WORK`].
work: usize,
/// Inside clipping groups, the box of what their clipping paths show together.
shown: Option<Rect>,
}
impl Resolver<'_> {
/// Count `steps` more objects visited or made, or an error past [`MAX_WORK`].
fn spend(&mut self, steps: usize) -> Result<(), String> {
self.work = self.work.saturating_add(steps);
if self.work > MAX_WORK {
return Err(self.too_much_work());
}
Ok(())
}
fn too_much_work(&self) -> String {
format!("{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects", self.label)
}
/// `f` run on the art of `c`. The effects of an object or group can move its art into a clip,
/// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]).
fn inside<T>(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result<T, String>) -> Result<T, String> {
let outer = self.shown;
if has_effects(c) {
self.shown = None;
}
let out = f(self);
self.shown = outer;
out
}
/// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one,
/// shows, as the members of a plain group.
fn content(&mut self, n: &Node, depth: usize) -> Result<Vec<Arc<Node>>, String> {
let label = self.label;
if depth > MAX_DEPTH {
return Err(self.too_deep());
}
self.spend(1)?;
match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
// 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")
})?;
Ok(self.list(kids(&art), depth + 1)?.0)
}
NodeKind::Blend { children, spec } => {
// Counted before the steps are made, so a blend past the limit is never expanded.
let made = vectorcraft_doc::blend::blend_steps(children, spec).into_iter().fold(children.len(), usize::saturating_add);
if self.leaves.saturating_add(made) > MAX_MEMBERS {
return Err(self.too_many());
}
self.spend(made)?;
let steps: Vec<Arc<Node>> = crate::expand_live(self.doc, n).into_iter().map(Arc::new).collect();
Ok(self.list(&steps, depth + 1)?.0)
}
_ if n.clips() => {
let Some((first, rest)) = kids(n).split_first() else { return Ok(vec![]) };
if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) {
return Err(self.too_deep());
}
let clip = clip_area(first);
// An image wholly outside every clip around it shows nothing and is left out.
let outer = self.shown;
let here = clip.0.bounds().unwrap_or(Rect::ZERO);
self.shown = Some(outer.map_or(here, |o| o.intersect(here)));
let listed = self.list(rest, depth + 1);
self.shown = outer;
let (inner, _) = listed?;
let mut out = vec![];
for c in &inner {
if let Some(c) = self.clipped(c, &clip, depth + 1)? {
out.push(Arc::new(c));
}
}
Ok(out)
}
_ => Ok(self.list(kids(n), depth + 1)?.0),
}
}
/// The visible members of `children` as plain art: images refused, guides left out, symbol
/// instances, blends and clipping groups resolved into groups with their transparency and
/// geometry effects, groups holding them rebuilt. Also whether any member changed: a group
/// whose members did not is kept as it is. Each object is visited once.
fn list(&mut self, children: &[Arc<Node>], depth: usize) -> Result<(Vec<Arc<Node>>, bool), String> {
let mut out = Vec::with_capacity(children.len());
let mut changed = false;
for c in children {
self.spend(1)?;
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
continue;
}
if matches!(c.kind, NodeKind::Image(_)) {
// Only an image with no effects or fills and strokes of its own is measured by its
// frame; any other one may draw inside the clip.
let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible());
let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0));
if hidden {
changed = true;
continue;
}
return Err(image_refusal(self.label));
}
if resolves(c) {
let kids = self.inside(c, |r| r.content(c, depth))?;
out.push(Arc::new(with_effects(wrap(c, kids), c)));
changed = true;
continue;
}
if is_container(c) {
// At the depth limit a group is kept, and not walked further, only when it holds no
// group with members and nothing in it needs resolving.
if depth >= MAX_DEPTH {
if nested_past(c, 1) || needs(c) {
return Err(self.too_deep());
}
out.push(c.clone());
continue;
}
let (kids, inner) = self.inside(c, |r| r.list(kids(c), depth + 1))?;
if inner {
out.push(Arc::new(with_effects(wrap(c, kids), c)));
changed = true;
} else {
out.push(c.clone());
}
continue;
}
self.leaves += 1;
if self.leaves > MAX_MEMBERS {
return Err(self.too_many());
}
out.push(c.clone());
}
Ok((out, changed))
}
fn too_complex(&self) -> String {
format!("{}: the clipping path is too complex to cut the art with", self.label)
}
fn too_many(&self) -> String {
format!("{} takes at most {MAX_MEMBERS} objects", self.label)
}
fn too_deep(&self) -> String {
format!("{} can't build solids from art nested more than {MAX_DEPTH} levels deep", self.label)
}
/// `n`, plain art inside a clipping group, cut to `clip`: each shape's fills as its area inside
/// the clip and each stroke as the part of its outline inside it, filled in the stroke's paint,
/// after the geometry effects of the shape and of the groups it is in; hidden objects and guides
/// left out. `None` when nothing of it is inside. Objects that build no solid stay as they are.
fn clipped(&mut self, n: &Node, clip: &(PathData, FillRule), depth: usize) -> Result<Option<Node>, String> {
if depth > MAX_DEPTH {
return Err(self.too_deep());
}
self.spend(1)?;
if is_container(n) {
let mut kids = vec![];
if n.appearance.effects.iter().any(|e| e.visible && is_geometry(&e.id)) {
// The group's geometry effects reshape its members together before the clip cuts
// them, as they are drawn: each member becomes its reshaped outline.
if nested_past(n, MAX_DEPTH.saturating_sub(depth)) {
return Err(self.too_deep());
}
let mut shapes = members(n, self.label)?;
reshape_members(n, &n.appearance.effects, &mut shapes);
let mut cut = vec![];
for s in shapes {
if let Some(c) = self.clipped(&shape(n, s.path, s.rule, s.items), clip, depth + 1)? {
cut.push((s.scopes, c));
}
}
kids = nest(n, cut, 0);
} else {
for c in n.children().into_iter().flatten() {
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
continue;
}
if let Some(c) = self.clipped(c, clip, depth + 1)? {
kids.push(Arc::new(c));
}
}
}
return Ok((!kids.is_empty()).then(|| wrap(n, kids)));
}
let Some((path, rule)) = profile(n) else { return Ok(Some(n.clone())) };
let items = solid_paints(n);
// The fills' piece goes where the top fill is, the one Extrude & Bevel and Inflate build
// their solid from, so the strokes above and below it keep their places. Rotate turns
// every fill, so each fill is a piece in its own place.
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let fill_pieces =
if self.every_fill { items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() } else { usize::from(top_fill.is_some()) };
// Each cut makes a piece, counted before it is made: the shape was counted once already.
let made = fill_pieces + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count();
self.spend(made)?;
self.leaves = self.leaves.saturating_add(made.saturating_sub(1));
if self.leaves > MAX_MEMBERS {
return Err(self.too_many());
}
let path = geometry(&path, &n.appearance.effects, &GeomContext::of(n));
let cut = |area: &PathData, rule: FillRule| -> Result<Option<PathData>, String> {
let inside =
po::try_boolean(area, rule, &clip.0, clip.1, po::BoolOp::Intersect, po::DEFAULT_PRECISION).map_err(|_| self.too_complex())?;
Ok(inside.bounds().is_some().then_some(inside))
};
let fills: Vec<AppearanceItem> = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect();
// Every fill paints the same area: it is cut once.
let mut fill_area: Option<Option<PathData>> = None;
let mut pieces = vec![];
for (k, item) in items.iter().enumerate() {
match item {
AppearanceItem::Fill(_) if self.every_fill || Some(k) == top_fill => {
let area = match &fill_area {
Some(area) => area.clone(),
None => fill_area.insert(cut(&path, rule)?).clone(),
};
if let Some(area) = area {
let painted = if self.every_fill { vec![item.clone()] } else { fills.clone() };
pieces.push(Arc::new(shape(n, area, FillRule::NonZero, painted)));
}
}
AppearanceItem::Fill(_) => {}
AppearanceItem::Stroke(s) => {
let (outline, outline_rule) = region(&path, rule, item);
if let Some(area) = cut(&outline, outline_rule)? {
let mut fill = FillLayer::new(s.paint.clone());
fill.opacity = s.opacity;
fill.blend = s.blend;
pieces.push(Arc::new(shape(n, area, FillRule::NonZero, vec![AppearanceItem::Fill(fill)])));
}
}
}
}
Ok((!pieces.is_empty()).then(|| wrap(n, pieces)))
}
}
/// The area clipping path `first` keeps, as the renderer clips by it ([`crate::clip_outline`]): a
/// nested clipping group's own clipping path, the union of a group's members. Empty when it keeps
/// nothing.
fn clip_area(first: &Node) -> (PathData, FillRule) {
crate::clip_outline(first).map_or_else(|| (PathData::default(), FillRule::NonZero), |(bp, rule)| (PathData::from_bezpath(&bp), rule))
}
/// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with
/// those groups' opacity 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<Scope>, Node)>, level: usize) -> Vec<Arc<Node>> {
let mut out = vec![];
let mut run: Vec<(Vec<Scope>, Node)> = vec![];
let mut key = None;
let close = |run: &mut Vec<(Vec<Scope>, Node)>, scope: Option<Scope>, out: &mut Vec<Arc<Node>>| {
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` carry a visible effect?
fn has_effects(n: &Node) -> bool {
n.appearance.effects.iter().any(|e| e.visible)
}
/// Does the group `n` hold shown objects nested more than `levels` deep in groups and layers?
/// Only the members [`members`] walks are followed: hidden members are skipped, and the paths of
/// a compound path are one shape. Walked without recursion.
fn nested_past(n: &Node, levels: usize) -> bool {
let mut stack = vec![(n, 0usize)];
while let Some((n, level)) = stack.pop() {
if level > levels {
return true;
}
if level == 0 || is_container(n) {
stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1)));
}
}
false
}
/// Does the clipping path `n` reach objects nested more than `levels` deep along the members that
/// [`Node::clip_shapes`] visits: the first member of a clipping group, the shown members of other
/// groups and layers (not a group's shaper), and every member of a live object. Walked without
/// recursion.
fn clip_nested_past(n: &Node, levels: usize) -> bool {
let mut stack = vec![(n, 0usize)];
while let Some((n, level)) = stack.pop() {
if level > levels {
return true;
}
match &n.kind {
NodeKind::Group { children, clip: true } => stack.extend(children.first().map(|c| (&**c, level + 1))),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
let skip = usize::from(n.extras().shaper.is_some());
stack.extend(children.iter().skip(skip).filter(|c| c.visible).map(|c| (&**c, level + 1)));
}
NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. } => {}
_ => stack.extend(kids(n).iter().map(|c| (&**c, level + 1))),
}
}
false
}
/// The members of `n`, none for an object without members.
fn kids(n: &Node) -> &[Arc<Node>] {
n.children().map_or(&[], Vec::as_slice)
}
/// A path of `n`'s id on `path`, painted by `items` under `rule`.
fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec<AppearanceItem>) -> Node {
let mut piece = Node::path(n.id, path, Appearance { items, ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut piece.kind {
*r = rule;
}
piece
}
/// A plain group of `children` with `n`'s id, visibility, opacity and blending mode.
fn wrap(n: &Node, children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(n.id, children);
g.name = n.name.clone();
g.opacity = n.opacity;
g.blend = n.blend;
g
}
/// `g`, the group `n` was resolved into, with `n`'s appearance: a 3D effect's group reshapes its
/// members with its geometry effects ([`members`]), which read its stroke as they do when `n` is
/// drawn. A group's own fills and strokes build no solid.
fn with_effects(mut g: Node, n: &Node) -> Node {
g.appearance = n.appearance.clone();
g
}
+5 -4
View File
@@ -591,13 +591,14 @@ 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!(err.contains("type, groups, clipping groups, blends and symbol instances"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
+5 -5
View File
@@ -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::<usize>();
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));
}
+487
View File
@@ -0,0 +1,487 @@
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, &params).unwrap());
n
}
fn art(d: &Document, n: &Node, id: &str) -> Option<Node> {
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 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;
}
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 {
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::<Vec<_>>();
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.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::<Vec<_>>();
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 {
deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]);
}
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 {
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}");
}
// 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;
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}");
}
}
/// 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}");
}
// 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));
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 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();
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:?}");
// 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();
for i in 0..200 {
deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]);
}
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;
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")));
}
+3 -2
View File
@@ -213,7 +213,7 @@ fn add_3d(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::<Result<Vec<_>>>()?;
// 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;
+9 -4
View File
@@ -282,11 +282,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
#[cfg(test)]
mod lighting_presets_tests;
#[cfg(test)]
mod three_d_source_tests;
#[cfg(test)]
mod three_d_type_tests;
+67 -20
View File
@@ -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
@@ -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::<Vec<_>>()})).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");
}
}
}
+2 -1
View File
@@ -445,7 +445,8 @@ fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Option<Rect>> {
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))
}
+1 -1
View File
@@ -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
+90
View File
@@ -612,6 +612,96 @@ 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, 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};
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 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 = [
("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 (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 =
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, "{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}");
}
}
fn doc_of(d: &mut Document, n: Node) -> Document {
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
@@ -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());
}
+2 -1
View File
@@ -4995,7 +4995,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 повърхността е твърде сложна; опростете формата
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5504,7 +5504,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5961,7 +5961,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5621,7 +5621,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5157,7 +5157,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5188,7 +5188,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -4982,7 +4982,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 είναι πολύ σύνθετη· απλοποιήστε το σχήμα
Can't render this file because it is too large.
+2 -1
View File
@@ -5562,7 +5562,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5505,7 +5505,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5192,7 +5192,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5431,7 +5431,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} पर होना चाहिए
Can't render this file because it is too large.
+2 -1
View File
@@ -5604,7 +5604,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5157,7 +5157,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
Can't render this file because it contains an unexpected character in line 2315 and column 5.
+2 -1
View File
@@ -5230,7 +5230,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5272,7 +5272,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 サーフェスが複雑すぎます。シェイプを単純化してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -4971,7 +4971,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 ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა
Can't render this file because it is too large.
+2 -1
View File
@@ -5191,7 +5191,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 표면이 너무 복잡합니다. 모양을 단순화하십시오
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5169,7 +5169,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5451,7 +5451,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5566,7 +5566,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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -5224,7 +5224,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-поверхность слишком сложна; упростите фигуру
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5096,7 +5096,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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2 -1
View File
@@ -4892,7 +4892,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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2 -1
View File
@@ -5210,7 +5210,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-поверхня надто складна; спростіть фігуру
Can't render this file because it contains an unexpected character in line 182 and column 5.
+2 -1
View File
@@ -5210,7 +5210,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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2 -1
View File
@@ -5491,7 +5491,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 表面过于复杂;请简化形状
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -5317,7 +5317,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 表面過於複雜;請簡化形狀
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -2
View File
@@ -2512,10 +2512,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),
@@ -2591,6 +2591,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`
+2 -2
View File
@@ -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.
+1
View File
@@ -558,6 +558,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.
+39 -7
View File
@@ -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.
@@ -118,13 +119,43 @@ 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
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.
- 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)).
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 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
@@ -232,9 +263,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 65,536 outline points and 163,840 faces for each
solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points.