diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e5b3201a4..82db95b5b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,7 +87,7 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_r 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_params, view_options, + validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index fd423689f..99a6e053e 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -3,10 +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, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; 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::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -60,6 +61,7 @@ pub fn revolve_options(p: &Value) -> Revolve { shade: flag(p, "shade", true), smooth_shading: true, cap_ends: flag(p, "cap", true), + curve_samples: None, } } @@ -67,12 +69,93 @@ pub fn revolve_options(p: &Value) -> Revolve { /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; - if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { - return Err("Revolve requires at least two anchors".into()); + let effects = &n.appearance.effects; + let before = effects.iter().position(|e| e.visible && e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + if is_container(n) { + const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; + let mut shapes = members(n, "Revolve")?; + if !shapes.iter().any(|s| has_two_anchors(&s.path)) { + return Err(EMPTY.into()); + } + reshape_members(n, before, &mut shapes); + let b = union_bounds(&shapes).ok_or(EMPTY)?; + let options = shared_options(&shapes, revolve_options(p))?; + let mut copies = 0; + return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies)); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err("Revolve requires a shape with at least two anchors".into()); + } + let path = geometry(&path, before, &GeomContext::of(n)); + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let paints = solid_paints(n); + check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) +} + +/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of +/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, +/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level +/// Revolve draws in place of those surfaces. +pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { + return validate_revolve(n, p); + } + validate_revolve_params(p)?; + const EMPTY: &str = "Revolve requires a shape with at least two anchors"; + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err(EMPTY.into()); + } + let ctx = GeomContext::of(n); + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut copies = 0; + for (i, item) in n.appearance.items.iter().enumerate() { + let effects = item.effects(); + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; + let options = match at.and_then(|at| effects.get(at)) { + _ if i == target => revolve_options(p), + Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)), + _ => continue, + }; + let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); + let b = path.bounds().ok_or(EMPTY)?; + check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + } + Ok(()) +} + +fn has_two_anchors(path: &PathData) -> bool { + path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) +} + +/// The bytes the copies of `paint` take on `faces` faces: a face's fill, its shadow and their seam +/// strokes make up to four copies. +fn paint_copies(faces: usize, paint: &Paint) -> usize { + faces.saturating_mul(4).saturating_mul(paint_bytes(paint)) +} + +/// An error when `path` does not revolve or when its surface in `item`'s paint takes `copies`, the +/// bytes of an object's paint copies so far, past [`MAX_PAINT_COPIES`]. +fn check_surface(path: &PathData, b: Rect, options: Revolve, item: Option<&AppearanceItem>, copies: &mut usize) -> Result<(), String> { + let faces = vectorcraft_three_d::revolve(path, b, options)?; + *copies = copies.saturating_add(item.map_or(0, |i| paint_copies(faces.len(), &paint_of(i).0))); + if *copies > MAX_PAINT_COPIES { + return Err("Revolve paint is too complex for this surface; reduce Segments or simplify the gradient".into()); + } + Ok(()) +} + +/// `options` with one number of samples for every curve of `shapes`, the members of one group: the +/// members share Revolve's limits. +fn shared_options(shapes: &[Profile], options: Revolve) -> Result { + let samples = vectorcraft_three_d::revolve_curve_samples(shapes.iter().map(|s| &s.path), options)?; + Ok(Revolve { curve_samples: Some(samples), ..options }) +} + +/// The item whose paint an object's surface takes: its top fill, else its top stroke. +fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { + items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } /// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. @@ -87,38 +170,31 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } -fn profile(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), - NodeKind::Compound { children, .. } => { - Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) - } - _ => None, - } -} - fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } +/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// the bytes of the paint copies of every surface of the object. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, path: &PathData, + b: Rect, + options: Revolve, fx: &Effect, paint: Paint, opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, -) -> Option { - let b = path.bounds()?; + copies: &mut usize, +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - // A face's fill, its shadow and their seam strokes: up to four copies of the paint. - if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); + if *copies > MAX_PAINT_COPIES { return None; } // Visibility is an expansion option, never a change to live geometry or export baking. @@ -146,6 +222,7 @@ fn surface( }) .collect() }); + let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); let children = faces .into_iter() .map(|face| { @@ -202,7 +279,16 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some(group) + Some((depths, group)) +} + +/// A surface of one object about the bounds of `path`, built with options derived from `fx`. +/// `copies` is the count [`surface`] adds to. +fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option { + let b = path.bounds()?; + let (paint, opacity, blend) = paint_of(item); + let options = revolve_options(&merged_params(REVOLVE, &fx.params)); + surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -237,26 +323,23 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes /// `total` past `limit` stops with an error before the next is built. pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { - let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + if is_container(n) { + return group_meshes(n, total, limit); + } + let (path, rule) = object_profile(n, "Revolve")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) - .ok_or("the revolved object has no fill or stroke")?; + let paints = solid_paints(n); + let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?; let mut out = vec![]; crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; return Ok(out); @@ -274,28 +357,49 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. +fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && e.id == REVOLVE) + .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mut out = vec![]; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + let (paint, opacity, _) = paint_of(item); + let mesh = vectorcraft_three_d::revolve_mesh(&s.path, s.rule, b, options)?; + crate::solids::keep_mesh(&mut out, total, crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity * s.opacity) }, limit)?; + } + Ok(out) +} + fn art(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let path = profile(n)?; + if is_container(n) { + return group_art(n, expand); + } + let (path, _) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; + let mut copies = 0; let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; - let (paint, opacity, blend) = paint_of(item); + let paints = solid_paints(n); + let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -312,9 +416,8 @@ fn art(n: &Node, expand: bool) -> Option { if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - let (paint, opacity, blend) = paint_of(item); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); children.push(Arc::new(art)); } else { @@ -328,6 +431,86 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` +/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity +/// and blending mode, so each applies once, to the members it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { + if let Some(scope) = scope { + let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); + group.opacity = scope.opacity; + group.blend = scope.blend; + out.push(Arc::new(group)); + } + } + let mut out = vec![]; + let mut run = vec![]; + let mut scope = None; + for (scopes, s) in surfaces { + let at = scopes.get(level).copied(); + if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + close(id, &mut run, scope, level, &mut out); + scope = at; + } + if scope.is_some() { + run.push((scopes, s)); + } else { + out.push(Arc::new(s)); + } + } + close(id, &mut run, scope, level, &mut out); + out +} + +/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, +/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// the effect is only on the group's own fills or strokes or when its members can't be revolved. +fn group_art(n: &Node, expand: bool) -> Option { + let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let fx = n.appearance.effects.get(index)?; + let mut shapes = members(n, "").ok()?; + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); + let b = union_bounds(&shapes)?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut copies = 0; + let mut surfaces = vec![]; + let mut opaque = true; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + // The opacity and blending of the member and the groups it is in apply through its scopes. + let (paint, opacity, blend) = paint_of(item); + opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); + // A member with transparency of its own keeps every face, as a transparent object does. + let expand = expand && s.scopes.is_empty(); + let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + surfaces.push((depths, art, s.scopes.as_slice())); + } + if surfaces.is_empty() { + return None; + } + let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + let children = if opaque && plain { + let mut faces = vec![]; + for (depths, s, _) in surfaces { + if let NodeKind::Group { children, .. } = s.kind { + faces.extend(depths.into_iter().zip(children)); + } + } + // A stable sort: faces at the same depth keep the members' order. + faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] + } else { + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) + }; + let remaining = + n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); + Some(art) +} + #[cfg(test)] mod tests { use super::*; @@ -495,6 +678,213 @@ mod tests { assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); } + /// A closed rectangle `x` to `x + 10` across, filled solid. + fn member(id: u64, x: f64) -> Arc { + let mut b = BezPath::new(); + b.move_to((x, 0.0)); + b.line_to((x + 10.0, 0.0)); + b.line_to((x + 10.0, 40.0)); + b.line_to((x, 40.0)); + b.close_path(); + Arc::new(Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))) + } + + fn group_with(params: Value, children: Vec>) -> Node { + let mut g = Node::group(NodeId(9), children); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + g + } + + #[test] + fn a_groups_members_share_the_axis_on_the_edge_of_them_all() { + let mut hidden = (*member(3, -500.0)).clone(); + hidden.visible = false; + let nested = Arc::new(Node::group(NodeId(5), vec![member(4, 90.0)])); + let children = vec![member(1, 0.0), nested, Arc::new(hidden)]; + // Left edge, 5 pt out: the axis at -5, the far wall (100) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"rotationX":0}), children.clone()); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x0 + 110.0).abs() < 1.5 && (b.x1 - 100.0).abs() < 1.5, "{b:?}"); + // Right edge, 5 pt out: the axis at 105, the far wall (0) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"edge":"right","rotationX":0}), children); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x1 - 210.0).abs() < 1.5 && (b.x0 - 0.0).abs() < 1.5, "{b:?}"); + } + + #[test] + fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); + let art = revolve_art(&g).unwrap(); + assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); + assert!(art.children().unwrap()[0].children().unwrap().len() > 16); + let mut faded = (*member(2, 50.0)).clone(); + faded.opacity = 0.5; + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); + let art = revolve_art(&g).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); + assert_eq!(surfaces[1].opacity, 0.5); + } + + fn star(id: u64) -> Arc { + let anchors = (0..1200) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / 600.0).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + Arc::new(Node::path( + NodeId(id), + PathData::new(vec![vectorcraft_geom::SubPath::new(anchors, true)]), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + } + + #[test] + fn a_groups_members_share_revolves_limits() { + let alone = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&alone, &json!({})).is_ok()); + let both = group_with(json!({}), vec![star(1), star(2)]); + let refused = validate_revolve(&both, &json!({})).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolve_art(&both), None, "past the limits the source draws"); + } + + #[test] + fn blending_modes_apply_once_to_the_member_or_group_that_has_them() { + use vectorcraft_doc::color::BlendMode; + let blend = |n: Arc, mode: BlendMode| { + let mut n = (*n).clone(); + n.blend = mode; + Arc::new(n) + }; + let inner = blend(member(4, 60.0), BlendMode::Multiply); + let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); + let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); + let art = revolve_art(&group).expect("revolved"); + let modes = |n: &Node| match &n.kind { + NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), + _ => panic!("a group"), + }; + assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let screened = &children[2]; + assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); + let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; + assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let plain = group_with(json!({}), vec![member(1, 0.0)]); + assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); + let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); + assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); + // A nested group's opacity applies once, to its members' surfaces together. + let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); + faded.opacity = 0.5; + let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); + assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); + assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); + } + + #[test] + fn a_3d_effect_on_a_groups_fill_is_refused() { + let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]); + group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); + for id in [REVOLVE, crate::EXTRUDE] { + assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself"); + let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err(); + assert!(refused.contains("on the group itself"), "{id}: {refused}"); + } + } + + #[test] + fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut group = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&group, &json!({})).is_ok()); + group.appearance.effects.insert(0, roughen.clone()); + assert!(validate_revolve(&group, &json!({})).is_err(), "the group's reshaped member"); + assert_eq!(revolve_art(&group), None); + let mut object = (*star(1)).clone(); + assert!(validate_revolve(&object, &json!({})).is_ok()); + object.appearance.effects.insert(0, roughen); + assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved"); + } + + #[test] + fn paint_copies_past_the_budget_are_refused_for_the_whole_group() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let painted = |id: u64, x: f64, stops: usize| { + 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 plain = member(1, 0.0); + let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let one = group_with(json!({}), vec![painted(1, 0.0, stops)]); + assert_eq!(validate_revolve(&one, &json!({})), Ok(())); + let two = group_with(json!({}), vec![painted(1, 0.0, stops), painted(2, 20.0, stops)]); + let refused = validate_revolve(&two, &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let object = painted(3, 0.0, stops * 2); + assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); + } + + #[test] + fn the_fills_of_one_object_share_the_budget_of_paint_copies() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let source = profile_node(); + let NodeKind::Path { path: profile, .. } = &source.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let fill = |stops: usize, revolved: bool| { + 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 item = AppearanceItem::Fill(FillLayer::new(paint)); + if revolved { + item.effects_mut().push(fx()); + } + item + }; + let with = |items: Vec| { + let mut n = source.clone(); + n.appearance = Appearance { items, ..Default::default() }.into(); + n + }; + let one = with(vec![fill(stops, true)]); + assert!(revolve_art(&one).is_some()); + assert_eq!(validate_item_revolve(&one, &json!({}), 0, 0), Ok(())); + let two = with(vec![fill(stops, true), fill(stops, true)]); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let refused = validate_item_revolve(&two, &json!({}), 1, 0).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + let second = with(vec![fill(stops, true), fill(stops, false)]); + assert!(validate_item_revolve(&second, &json!({}), 1, 0).is_err(), "the item the effect goes on counts"); + assert_eq!(validate_item_revolve(&second, &json!({}), 0, 0), Ok(()), "an item without a Revolve does not"); + let below = with(vec![fill(stops * 2, false), fill(1, false)]); + assert!(validate_item_revolve(&below, &json!({}), 0, 0).is_err(), "the item's own paint, not the top fill"); + assert_eq!(validate_item_revolve(&below, &json!({}), 1, 0), Ok(())); + } + + #[test] + fn an_items_revolve_is_validated_after_only_the_geometry_effects_before_it() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut object = (*star(1)).clone(); + let mut hidden = fx(); + hidden.visible = false; + let item = object.appearance.items.first_mut().unwrap(); + item.effects_mut().push(hidden); + item.effects_mut().push(roughen); + assert_eq!(validate_item_revolve(&object, &json!({}), 0, 0), Ok(()), "the roughen after it is not swept"); + assert!(validate_item_revolve(&object, &json!({}), 0, 2).is_err(), "a Revolve added after the roughen"); + } + #[test] fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { let mut n = profile_node(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3390cc71f..ab119192c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -138,7 +138,7 @@ pub fn inflate_options(p: &Value) -> Inflate { } /// The shape a solid is made from: a path's or a compound path's outline. -fn profile(n: &Node) -> Option<(PathData, FillRule)> { +pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { @@ -156,7 +156,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { /// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the /// fill and stroke of its first painted run (its characters' colours). -fn solid_paints(n: &Node) -> Vec { +pub(crate) fn solid_paints(n: &Node) -> Vec { let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { return own; @@ -207,29 +207,52 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); } - let (path, rule) = profile(n).ok_or_else(|| { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - })?; + let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// [`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. +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.") + } + }) +} + +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending +/// mode is not Normal. The members of one group share its `key`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Scope { + pub(crate) key: usize, + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, and the opacity of the objects it is in (1 for the object with the effect itself). -struct Profile { - path: PathData, - rule: FillRule, - items: Vec, - opacity: f32, +/// it, and the opacity of the objects it is in multiplied together (1 for the object with the +/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from +/// the outermost group down to the member itself (none for the object itself). Group Revolve +/// applies each once, to the surfaces of the members it holds together. +pub(crate) struct Profile { + pub(crate) path: PathData, + pub(crate) rule: FillRule, + pub(crate) items: Vec, + pub(crate) opacity: f32, + pub(crate) scopes: Vec, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? -fn is_container(n: &Node) -> bool { +pub(crate) fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) } /// The bounds of every shape together: the centre a group's members turn about. -fn union_bounds(shapes: &[Profile]) -> Option { +pub(crate) fn union_bounds(shapes: &[Profile]) -> Option { shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) } @@ -245,15 +268,26 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// effects and painted with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. -fn members(n: &Node, label: &str) -> Result, String> { - fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { +pub(crate) fn members(n: &Node, label: &str) -> Result, String> { + struct Walk<'a> { + label: &'a str, + out: Vec, + keys: usize, + } + fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { + let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; + let mut scopes = scopes.to_vec(); + if c.blend != BlendMode::Normal || c.opacity < 1.0 { + w.keys += 1; + scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); + } if is_container(c) { - walk(c, opacity, label, out)?; + walk(c, opacity, &scopes, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -266,19 +300,19 @@ fn members(n: &Node, label: &str) -> Result, String> { }; return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); }; - if out.len() >= MAX_MEMBERS { + if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - out.push(Profile { path, rule, items: solid_paints(c), opacity }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes }); } } Ok(()) } - let mut out = vec![]; - walk(n, 1.0, label, &mut out)?; - Ok(out) + let mut w = Walk { label, out: vec![], keys: 0 }; + walk(n, 1.0, &[], &mut w)?; + Ok(w.out) } /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked @@ -288,6 +322,19 @@ 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`] for an effect at `(item, index)`, index `index` of the effects of +/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself +/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the +/// 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> { + 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((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + _ => validate_three_d(n, id, p), + } +} + /// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. @@ -385,7 +432,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32 /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. -fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { +pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { if before.iter().any(|e| e.visible && is_geometry(&e.id)) && let Some(b) = union_bounds(shapes) { @@ -501,7 +548,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -517,7 +564,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -680,14 +727,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - for target in appearance_targets(s, p)? { + 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))?; - effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?; + // 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))?; 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.")); } @@ -589,7 +593,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d_at(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. @@ -865,8 +869,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container && effects::has_solid(&v) { - // A group's or layer's 3D effect: the shaded faces of its members' solids. + let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) { + // A 3D effect on a group or layer, or on type: the shaded faces of its solids. effects::expand_leaf(d, &v, &mut stroke_art) } else if container { // A group's or layer's own fills and strokes become art among its members. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 088094e55..add1f3b79 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -193,6 +193,153 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() { assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } +/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves, +/// exports, expands and undoes as one step. +#[test] +fn type_revolves_as_its_glyphs_and_stays_editable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + let art = effects::revolve_art(&n).expect("a surface of the glyphs"); + let mut tinted = false; + art.walk(&mut |c| { + if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + tinted |= r > g && r > b; + } + }); + assert!(tinted, "shaded from the characters' red"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save"); + let meshes = effects::three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1, "one mesh of every contour of the type"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Text(_))); + assert!(effects::has_revolve(&node(&s, id))); + // Extrude & Bevel on type expands the same way. + s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, id)), "expanded"); +} + +/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an +/// effect. Extrude & Bevel gives the same reason for empty type. +#[test] +fn revolve_refuses_type_and_groups_without_a_shape() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + for text in ["", " "] { + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + for effect in ["threeD.revolve", "threeD.extrude"] { + let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}"); + assert!(node(&s, id).appearance.effects.is_empty()); + } + } + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + for member in [a.0, b] { + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + } + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("group holding a shape"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + +/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well. +#[test] +fn revolve_on_a_fill_is_checked_in_that_fills_paint() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{AppearanceItem, FillLayer}; + let (mut s, id) = session_with_rect(); + // The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it. + let count = (256 << 20) / (4 * 100 * std::mem::size_of::()); + let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect(); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("fills", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.items = + vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))]; + Ok(()) + }) + .unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); + assert!(node(&s, id).appearance.items[0].effects().is_empty()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); +} + +/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps +/// each member's paint, follows an edited member, expands and undoes as one step. +#[test] +fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { + let (mut s, a) = session_with_rect(); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were"); + let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members"); + // The axis is the left edge of both members together (x = 100), so the right member's far + // wall sweeps out to 200 pt from it on either side: x = 300 and x = -100. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}"); + let (mut red, mut blue) = (false, false); + art.walk(&mut |c| { + if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + red |= r > b; + blue |= b > r; + } + }); + assert!(red && blue, "each member keeps its own paint"); + assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, g)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + 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. +#[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()); +} + /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. #[test] diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 80f338736..5c4474b22 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -900,8 +900,8 @@ fn the_brush_library_is_checked_only_after_3d_art_is_measured() { assert_eq!(r.brush_library, None); } -/// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the -/// free measure does it: as the renderer draws it, by its members. +/// An object whose 3D effect builds nothing (Revolve on a group's fill) is measured by the renderer +/// as the free measure does it: as the renderer draws it, by its members. #[test] fn a_3d_effect_that_builds_nothing_is_measured_alike() { let mut d = Document::new(100.0, 100.0); @@ -917,10 +917,12 @@ fn a_3d_effect_that_builds_nothing_is_measured_alike() { Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); let mut g = Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]); - g.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; + g.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into(); + *g.appearance.items[0].effects_mut() = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; let l = d.layers[0].id; d.insert(Some(l), 0, g).unwrap(); let n = d.layers[0].children().unwrap()[0].clone(); + assert!(crate::effects::has_revolve(&n) && crate::effects::revolve_art(&n).is_none(), "builds nothing"); let symbols = d.symbol_boxes(); assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n)); assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n)); @@ -1761,8 +1763,8 @@ fn an_image_inner_glow_follows_its_visible_pixels() { } } -/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the -/// item as without it: the art made for the object is drawn without it, not as an object with +/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen, in a paint +/// whose copies on the faces would pass the budget) draws the item as without it: the art made for the object is drawn without it, not as an object with /// effects again, and that art again, without end. A stroked object draws exactly as without the /// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with /// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a @@ -1775,6 +1777,12 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let rough = Effect { id: "distort.roughen".into(), params: json!({"size": 10, "relative": false, "detail": 10, "points": "smooth"}), visible: true }; let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }; + // A gradient of one color with 1,000 stops. + let ramp = |c: Color| { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = (0..1000).map(|i| GradientStop::new(i as f32 / 1000.0, c)).collect(); + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))) + }; let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true }; let cases = [ @@ -1789,8 +1797,8 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { for (object, on_fill, stroked) in cases { let draw = |object: &[Effect], three: bool| { let mut d = Document::new(100.0, 100.0); - let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None }; - let ap = Appearance::basic(Paint::solid(Color::BLACK), stroke, 2.0); + let stroke = if stroked { ramp(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None }; + let ap = Appearance::basic(ramp(Color::BLACK), stroke, 2.0); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap); let l = d.layers[0].id; let id = d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 6ce5f3501..479481c2a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -18,11 +18,16 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; /// Resource limits shared by desktop, CLI and browser evaluation. -const MAX_PROFILE_POINTS: usize = 1024; +const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; +/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. +const MAX_REVOLVE_FACES: usize = 163_840; +/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. +const CURVE_SAMPLES: usize = 16; +const MIN_CURVE_SAMPLES: usize = 4; #[derive(Clone, Copy, Debug, Default, PartialEq)] struct V3(f64, f64, f64); @@ -86,6 +91,9 @@ pub struct Revolve { /// Close circular endpoint rings of open profiles. Partial sweeps get sectors; /// the angular cut sides are left open. Enabled by default. pub cap_ends: bool, + /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of + /// one object. `None` plans them from the profile alone. + pub curve_samples: Option, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -114,6 +122,7 @@ impl Default for Revolve { shade: true, smooth_shading: true, cap_ends: true, + curve_samples: None, } } } @@ -307,6 +316,11 @@ impl Revolve { ..self } } + /// How many steps a sanitized sweep takes. + fn steps(self) -> usize { + ((self.segments as f64 * self.angle / 360.0).ceil() as usize).max(1) + } + fn rotate(self, v: V3) -> V3 { let (sx, cx) = self.rotation_x.to_radians().sin_cos(); let (sy, cy) = self.rotation_y.to_radians().sin_cos(); @@ -449,16 +463,230 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { - revolve_faces(path, bounds, options, None) + revolve_faces(path, FillRule::NonZero, bounds, options, None) } -/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. -fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { +const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; +const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; + +/// The samples each curved segment takes when `paths`, the profiles of one object's surfaces, are +/// revolved with `options`: [`CURVE_SAMPLES`] when every surface fits the limits together, else as +/// many as fit, the same number for every curve. An error when [`MIN_CURVE_SAMPLES`] do not fit. +/// The count is made before any face is built. +pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, options: Revolve) -> Result { let o = options.sanitized(); + let steps = o.steps(); + let per_edge = if o.shade && o.smooth_shading { steps.saturating_mul(2) } else { steps }; + let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); + for sp in paths.into_iter().flat_map(|p| &p.subpaths) { + anchors = anchors.saturating_add(sp.anchors.len()); + if anchors > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + contours += 1; + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..segments { + if sp.segment_is_line(i) { + lines += 1; + } else { + curves += 1; + } + } + if o.cap_ends && !sp.closed && segments > 0 { + caps += 2; + } + } + let points = MAX_PROFILE_POINTS.saturating_sub(contours); + if lines.saturating_add(curves.saturating_mul(MIN_CURVE_SAMPLES)) > points { + return Err(TOO_COMPLEX_PROFILE.into()); + } + let edges = (MAX_REVOLVE_FACES.saturating_sub(caps.saturating_mul(steps)) / per_edge).min(points); + if curves == 0 { + return if lines > edges { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(CURVE_SAMPLES) }; + } + let samples = (edges.saturating_sub(lines) / curves).min(CURVE_SAMPLES); + if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } +} + +/// Most work the search for holes does: each winding test counts its contour's elements, and each +/// contour placed in a cell of the grid or looked at as a candidate for a point counts one. +const MAX_HOLE_WORK: usize = 16_000_000; + +/// Most cells along each side of the grid the search for holes sorts contours into. +const MAX_HOLE_GRID: usize = 256; + +/// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is +/// outside its closed contour at the segment's middle and not inside. An error past +/// [`MAX_HOLE_WORK`]. +#[cfg(test)] +fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + hole_segments_counted(path, rule).map(|(holes, _)| holes) +} + +/// [`hole_segments`] and the work it did. +#[cfg(test)] +fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { + let mut search = HoleSearch::new(path, rule)?; + let mut holes = Vec::with_capacity(path.subpaths.len()); + for (i, sp) in path.subpaths.iter().enumerate() { + let segments = if sp.closed { sp.anchors.len() } else { 0 }; + holes.push((0..segments).map(|segment| search.is_hole(i, sp.segment(segment).eval(0.5))).collect::, _>>()?); + } + Ok((holes, search.work)) +} + +/// The closed contours of a profile sorted into a grid over the bounds of them all, each listed in +/// the cells its bounds reach into, to find which parts of the profile's edges wall a hole under +/// its fill rule. A point is tested only against the contours of its cell. Every test counts +/// toward [`MAX_HOLE_WORK`]. +struct HoleSearch { + /// Each subpath's outline, its bounds, and for a closed one which way it winds. + contours: Vec<(BezPath, Rect, Option)>, + rule: FillRule, + area: Option, + side: usize, + grid: Vec>, + work: usize, +} + +impl HoleSearch { + fn new(path: &PathData, rule: FillRule) -> Result { + let contours: Vec<(BezPath, Rect, Option)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + let direction = sp.closed.then(|| if contour.area() < 0.0 { -1 } else { 1 }); + (contour, bounds, direction) + }) + .collect(); + let closed = || contours.iter().enumerate().filter(|(_, (_, _, d))| d.is_some()); + let area = closed().map(|(_, (_, b, _))| *b).reduce(|a, b| a.union(b)); + let side = ((closed().count() as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let mut search = HoleSearch { contours: vec![], rule, area, side, grid: vec![vec![]; side * side], work: 0 }; + for (j, (_, b, _)) in closed() { + let cells = search.cells(*b); + search.spend(cells.len())?; + for c in cells { + if let Some(c) = search.grid.get_mut(c) { + c.push(j); + } + } + } + search.contours = contours; + Ok(search) + } + + fn spend(&mut self, n: usize) -> Result<(), String> { + self.work = self.work.saturating_add(n); + if self.work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + } + + /// The grid cells `r` reaches into. + fn cells(&self, r: Rect) -> Vec { + let Some(a) = self.area else { return vec![] }; + let side = self.side; + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let (x0, x1) = (at(r.x0, a.x0, a.x1), at(r.x1, a.x0, a.x1)); + let (y0, y1) = (at(r.y0, a.y0, a.y1), at(r.y1, a.y0, a.y1)); + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| y * side + x)).collect() + } + + /// The closed contours other than `own` whose bounds may meet `r`, each once. + fn candidates(&mut self, own: usize, r: Rect) -> Result, String> { + let mut out = vec![]; + for c in self.cells(r) { + let listed = self.grid.get(c).map_or(&[][..], Vec::as_slice); + out.extend(listed.iter().copied().filter(|j| *j != own)); + } + self.spend(out.len())?; + out.sort_unstable(); + out.dedup(); + Ok(out) + } + + /// Whether the edge of closed subpath `own` at `p` walls a hole: the fill is outside its + /// contour there and not inside. + fn is_hole(&mut self, own: usize, p: Point) -> Result { + let Some(direction) = self.contours.get(own).and_then(|c| c.2) else { return Ok(false) }; + let mut others = 0i32; + for j in self.candidates(own, Rect::from_points(p, p))? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if !bounds.contains(p) { + continue; + } + let (elements, winding) = (contour.elements().len(), contour.winding(p)); + self.spend(elements)?; + others += winding; + } + Ok(match self.rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }) + } + + /// The line from `a` to `b` on subpath `own`, cut where it crosses another closed contour: each + /// part's end, the last one `b`, and whether that part walls a hole at its middle. + fn parts(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + let mut out = vec![]; + let mut from = 0.0; + for t in self.crossings(own, a, b)?.into_iter().chain([1.0]) { + let hole = self.is_hole(own, a.lerp(b, (from + t) * 0.5))?; + out.push((if t < 1.0 { a.lerp(b, t) } else { b }, hole)); + from = t; + } + Ok(out) + } + + /// Where the line from `a` to `b` on closed subpath `own` crosses another closed contour, as + /// sorted fractions of the way from `a` to `b`, strictly between them. + fn crossings(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + if self.contours.get(own).and_then(|c| c.2).is_none() { + return Ok(vec![]); + } + let line = vectorcraft_geom::kurbo::Line::new(a, b); + let reach = Rect::from_points(a, b); + let mut out = vec![]; + for j in self.candidates(own, reach)? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if bounds.x0 > reach.x1 || bounds.x1 < reach.x0 || bounds.y0 > reach.y1 || bounds.y1 < reach.y0 { + continue; + } + let found: Vec = contour.segments().flat_map(|s| s.intersect_line(line)).map(|i| i.line_t).collect(); + self.spend(contour.elements().len())?; + out.extend(found.into_iter().filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9)); + } + out.sort_by(f64::total_cmp); + out.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + Ok(out) + } +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, +/// each turned out of the region `rule` fills. +fn revolve_faces( + path: &PathData, + rule: FillRule, + bounds: Rect, + options: Revolve, + mut mesh: Option<&mut Vec>, +) -> Result, String> { + let o = options.sanitized(); + let curve_samples = match o.curve_samples { + Some(samples) => samples.clamp(1, CURVE_SAMPLES), + None => revolve_curve_samples([path], o)?, + }; + // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so + // each part's normal turns toward a hole only where it walls one. + let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -470,7 +698,7 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let center_y = (bounds.y0 + bounds.y1) * 0.5; let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); - let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let steps = o.steps(); let sweep = o.angle.to_radians(); let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); @@ -489,32 +717,33 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_FACES { - return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + if mesh_cost > MAX_REVOLVE_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } } Ok(()) }; let mut count = 0; let mut curve_budget = 0; - for sp in &path.subpaths { + for (contour, sp) in path.subpaths.iter().enumerate() { let mut profile = Vec::new(); let mut tangents = Vec::new(); + let mut hole_edges = Vec::new(); if let Some(first) = sp.anchors.first() { profile.push(first.p); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; - // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + // Bound work before sampling any data. Straight edges get one sample. if segments > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + let samples = if sp.segment_is_line(i) { 1 } else { curve_samples }; for k in 1..=samples { let t0 = (k - 1) as f64 / samples as f64; let t1 = k as f64 / samples as f64; @@ -536,11 +765,19 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - tangents.push([tangent(t0), tangent(t1)]); - profile.push(b); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + let a = profile.last().copied().unwrap_or(b); + let parts = match search.as_mut() { + Some(search) => search.parts(contour, a, b)?, + None => vec![(b, false)], + }; + for (end, hole) in parts { + tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(hole); + profile.push(end); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } } } } @@ -565,8 +802,9 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; - for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; + let turn = if hole { -turn } else { turn }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 2f2f69844..d1970c202 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -76,10 +76,11 @@ pub fn inflate_meshes( } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than -/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). -pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). The +/// walls of holes under `rule`, such as the counter of an O, face into the hole. +pub fn revolve_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result { let mut faces = vec![]; - crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?; mesh(&faces) } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 3251cbb3e..2011d6689 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -231,7 +231,7 @@ fn a_revolved_profile_is_a_closed_cylinder() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(closed(&m)); let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); @@ -246,7 +246,7 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } @@ -261,7 +261,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); assert!(closed(&m), "{from} to {to}"); @@ -269,6 +269,62 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct } } +/// The radial direction of every triangle's normal at the wall `x` pt from the axis, each as the +/// sign of the normal's part along the way from the axis: 1 away from it, -1 toward it. +fn wall_directions(m: &Mesh, x: f64) -> Vec { + m.triangles + .iter() + .filter_map(|t| { + let [a, b, c] = corners(m, *t); + let centre = [(a[0] + b[0] + c[0]) / 3.0, 0.0, (a[2] + b[2] + c[2]) / 3.0]; + let radius = centre[0].hypot(centre[2]); + let n = m.normals[t[0] as usize]; + ((radius - x).abs() < 1.0 && n[1].abs() < 0.5).then(|| dot(n, centre).signum()) + }) + .collect() +} + +#[test] +fn the_wall_of_a_counter_faces_into_the_hole() { + // A square ring 20 pt thick, its axis at the outer square's left edge minus 10: the outer wall + // reaches 110 pt from the axis, and the hole's far wall 90 pt from it. + let ring = |hole_clockwise: bool, rule: FillRule| { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 20.0, 20.0, 60.0, hole_clockwise); + let (p, r) = shape(&b); + revolve_mesh(&p, rule, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap() + }; + for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { + let m = ring(clockwise, rule); + let outer = wall_directions(&m, 110.0); + assert!(!outer.is_empty() && outer.iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let hole = wall_directions(&m, 90.0); + assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); + } + // Wound the same way under the nonzero rule, the inner square is not a hole. + let m = ring(false, FillRule::NonZero); + let inner = wall_directions(&m, 90.0); + assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); +} + +#[test] +fn an_edge_that_crosses_another_contour_faces_the_hole_only_where_it_walls_it() { + // Two squares overlapping under the even-odd rule leave their overlap empty. The first square's + // right edge (110 pt from the axis) and the second's left edge (50 pt from it) each wall the + // overlap along part of their length and the outside along the rest. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 40.0, 40.0, 100.0, false); + let (p, r) = shape(&b); + let m = revolve_mesh(&p, FillRule::EvenOdd, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap(); + for radius in [110.0, 50.0] { + let walls = wall_directions(&m, radius); + assert!(walls.iter().any(|s| *s > 0.0) && walls.iter().any(|s| *s < 0.0), "both ways at {radius}"); + } + assert!(wall_directions(&m, 150.0).iter().all(|s| *s > 0.0), "the second square's far edge faces out"); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index e2b6985fb..1b0ffa3ff 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -4,6 +4,7 @@ mod caps; mod classic; mod lighting; mod multiple_lights; +mod profile_limits; mod smooth; #[test] diff --git a/crates/three-d/src/tests/caps.rs b/crates/three-d/src/tests/caps.rs index 32fc5ac0b..cae43f4a5 100644 --- a/crates/three-d/src/tests/caps.rs +++ b/crates/three-d/src/tests/caps.rs @@ -93,14 +93,14 @@ fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() { assert!(faces.len() <= 384); let mut excessive = BezPath::new(); excessive.move_to((40.0, 0.0)); - for i in 1..=1024 { + for i in 1..=MAX_PROFILE_POINTS { excessive.line_to((40.0, i as f64)); } - assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err()); + assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, MAX_PROFILE_POINTS as f64), options()).is_err()); assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty()); // Viewed along the axis, the side walls disappear but the caps still consume // the same face budget. Repeated rings cannot bypass it through degenerate walls. - let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]); + let repeated = PathData::new(vec![path.subpaths[0].clone(); 1025]); let o = Revolve { segments: 128, ..options() }; assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty()); assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex")); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs new file mode 100644 index 000000000..5eeb10f2a --- /dev/null +++ b/crates/three-d/src/tests/profile_limits.rs @@ -0,0 +1,139 @@ +use crate::*; +use vectorcraft_geom::{Anchor, SubPath}; + +/// A closed star of `points` tips: twice as many straight edges. +fn star(points: usize) -> PathData { + let anchors = (0..points * 2) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + Anchor::corner(Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// An open wave of `anchors` smooth anchors: one curved segment between each two. +fn wave(anchors: usize) -> PathData { + let anchors = (0..anchors) + .map(|i| { + let p = Point::new(100.0 + if i % 2 == 0 { 0.0 } else { 20.0 }, i as f64); + Anchor::smooth(p, p + Vec2::new(6.0, 0.3)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, false)]) +} + +fn revolved(path: &PathData, options: Revolve) -> Result, String> { + revolve(path, path.bounds().unwrap(), options) +} + +#[test] +fn a_star_of_600_points_revolves_at_the_default_segments() { + let faces = revolved(&star(600), Revolve::default()).unwrap(); + // The tip on the axis gives two edges with a side of no length. + assert!((1198 * 64 * 2..=1200 * 64 * 2).contains(&faces.len()), "{}", faces.len()); + let refused = revolved(&star(600), Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); +} + +#[test] +fn a_curved_profile_past_the_old_limit_takes_fewer_samples_for_each_curve() { + let wave = wave(200); + assert!(revolved(&wave, Revolve::default()).is_ok()); + let samples = revolve_curve_samples([&wave], Revolve::default()).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); +} + +#[test] +fn a_profile_within_the_limits_keeps_every_sample() { + assert_eq!(revolve_curve_samples([&wave(50)], Revolve::default()), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&star(5)], Revolve::default()), Ok(CURVE_SAMPLES)); +} + +#[test] +fn two_thousand_curves_fit_at_low_segments_and_are_refused_before_any_face_at_high() { + let wave = wave(2001); + let samples = revolve_curve_samples([&wave], Revolve { segments: 8, ..Revolve::default() }).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); + assert!(revolved(&wave, Revolve { segments: 8, ..Revolve::default() }).is_ok()); + let refused = revolve_curve_samples([&wave], Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolved(&wave, Revolve { segments: 128, ..Revolve::default() }), Err(refused)); +} + +#[test] +fn too_many_anchors_are_refused_as_a_profile() { + let refused = revolve_curve_samples([&wave(MAX_PROFILE_POINTS + 1)], Revolve { segments: 8, ..Revolve::default() }).unwrap_err(); + assert_eq!(refused, TOO_COMPLEX_PROFILE); +} + +#[test] +fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { + let straight = |anchors: usize| { + let anchors = (0..anchors).map(|i| Anchor::corner(Point::new(100.0 + (i % 2) as f64, i as f64))).collect(); + PathData::new(vec![SubPath::new(anchors, false)]) + }; + let narrow = Revolve { angle: 1.0, cap_ends: false, segments: 8, ..Revolve::default() }; + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS)], narrow), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS + 1)], narrow), Err(TOO_COMPLEX_PROFILE.to_string())); +} + +#[test] +fn the_profiles_of_one_object_share_one_budget() { + let (one, other) = (star(600), star(600)); + let o = Revolve::default(); + assert!(revolve_curve_samples([&one], o).is_ok()); + assert!(revolve_curve_samples([&other], o).is_ok()); + let refused = revolve_curve_samples([&one, &other], o).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + // Contours of one profile count together as well. + let both = PathData::new(one.subpaths.iter().chain(&other.subpaths).cloned().collect()); + assert_eq!(revolve_curve_samples([&both], o), Err(refused)); +} + +#[test] +fn deeply_nested_contours_stop_the_search_for_holes() { + // Concentric triangles: each tests every contour outside it. + let triangle = |r: f64| { + let anchors = [(0.0, -r), (r, r), (-r, r)].iter().map(|(x, y)| Anchor::corner(Point::new(200_000.0 + x, 200_000.0 + y))).collect(); + SubPath::new(anchors, true) + }; + let nested = PathData::new((1..=4000).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&nested, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string())); + let few = PathData::new((1..=4).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); +} + +#[test] +fn a_point_is_tested_only_against_the_contours_near_it() { + // 3,000 separate squares: each segment's middle lies in the bounds of its own square only. + let squares = (0..3000) + .map(|i| { + let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0); + let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }) + .collect(); + let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap(); + assert!(holes.iter().flatten().all(|hole| !hole)); + assert!(work < 200_000, "{work}"); +} + +#[test] +fn overlapping_contours_are_holes_only_where_they_overlap() { + let square = |x: f64, y: f64, side: f64| { + let corners = [(x, y), (x + side, y), (x + side, y + side), (x, y + side)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }; + let overlapping = PathData::new(vec![square(0.0, 0.0, 100.0), square(40.0, 40.0, 100.0)]); + let expected = vec![vec![false, true, true, false], vec![true, false, false, true]]; + assert_eq!(hole_segments(&overlapping, FillRule::EvenOdd), Ok(expected)); +} + +#[test] +fn given_samples_are_used_for_each_curve() { + let wave = wave(4); + let at = |samples| revolved(&wave, Revolve { curve_samples: Some(samples), cap_ends: false, segments: 8, ..Revolve::default() }).unwrap().len(); + assert_eq!(at(4) * 2, at(8)); +} diff --git a/crates/three-d/src/tests/smooth.rs b/crates/three-d/src/tests/smooth.rs index c65172326..085255ecb 100644 --- a/crates/three-d/src/tests/smooth.rs +++ b/crates/three-d/src/tests/smooth.rs @@ -118,6 +118,6 @@ fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() { } assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty()); let mut p = path("M40 0"); - p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); + p.subpaths[0].anchors.extend((1..1100).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err()); } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5..65cdc909e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5917,7 +5917,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} actualment requereix un traçat o un traçat compost. Converteix primer els textos, les imatges i els grups en traçats. +@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} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots. @msg {label} produced an empty result {label} ha produït un resultat buit @msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f..951c6a24f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5587,7 +5587,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} zatím vyžaduje cestu nebo složenou cestu. Nejprve převeďte text, obrazy a skupiny na cesty. +@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} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe45..5eadf8841 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5154,7 +5154,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} erfordert derzeit einen Pfad oder zusammengesetzten Pfad. Wandeln Sie Text, Bilder und Gruppen zuerst in Pfade um. +@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} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec..d5018e6c0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4948,7 +4948,7 @@ 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 or compound path. Convert type, images and groups to paths first. Το {label} απαιτεί προς το παρόν διαδρομή ή σύνθετη διαδρομή. Μετατρέψτε πρώτα το κείμενο, τις εικόνες και τις ομάδες σε διαδρομές. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a1..53b28f3c4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5528,7 +5528,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} requiere por ahora un trazado o un trazado compuesto. Convierta primero el texto, las imágenes y los grupos en trazados. +@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} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27..4f3909656 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5471,7 +5471,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} vaatii tällä hetkellä polun tai yhdistelmäpolun. Muunna teksti, kuvat ja ryhmät ensin poluiksi. +@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} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea..5e99a492e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5158,7 +5158,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} nécessite pour l'instant un tracé ou un tracé transparent. Convertissez d'abord le texte, les images et les groupes en tracés. +@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} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2d..28b5dee07 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5400,7 +5400,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} वर्तमान में एक पाथ या यौगिक पाथ की आवश्यकता होती है। पहले पाथ के लिए प्रकार, इमेज और समूहों को परिवर्तित करें। +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e4..4eebab8e4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5570,7 +5570,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} egyelőre görbét vagy összetett görbét igényel. Előbb alakítsa görbévé a szöveget, a képeket és a csoportokat. +@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} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566..1b505fd8c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5196,7 +5196,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} richiede per ora un tracciato o un tracciato composto. Convertite prima testo, immagini e gruppi in tracciati. +@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} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8a..02db5f548 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5238,7 +5238,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} には現在、パスまたは複合パスが必要です。テキスト、画像、グループは先にパスに変換してください。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253..215ab1303 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4959,7 +4959,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label}: ამჟამად საჭიროა ტრაექტორია ან შედგენილი ტრაექტორია. ჯერ გარდაქმენით ტექსტი, გამოსახულებები და ჯგუფები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c..1d87de130 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5157,7 +5157,7 @@ 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 or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd05..255c66e96 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5417,7 +5417,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} wymaga obecnie ścieżki lub ścieżki złożonej. Najpierw przekonwertuj tekst, obrazy i grupy na ścieżki. +@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} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0c..1c75f2623 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5532,7 +5532,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} exige, por enquanto, um caminho ou caminho composto. Converta antes texto, imagens e grupos em caminhos. +@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} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a3..c28b7bff3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5190,7 +5190,7 @@ 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 or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b..df8c96bb8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5062,7 +5062,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} kräver för närvarande en bana eller sammansatt bana. Konvertera text, bilder och grupper till banor 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} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebd..9d3b1c60f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4858,7 +4858,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} şu anda bir yol veya bileşik yol gerektiriyor. Önce türü, görüntüleri ve grupları yollara dönüştü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} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e..d8b04db78 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5176,7 +5176,7 @@ 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 or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c0279792..2e462e950 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5176,7 +5176,7 @@ 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 or compound path. Convert type, images and groups to paths first. {label} hiện yêu cầu một đường dẫn hoặc đường dẫn phức. Hãy chuyển văn bản, hình ảnh và nhóm thành đường dẫn trước. +@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} produced an empty result {label} tạo ra kết quả trống @msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo @msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7b8f6561..2f21b50f1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5457,7 +5457,7 @@ 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 or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 328cbb1f8..cee3fb51c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5283,7 +5283,7 @@ 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 or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 diff --git a/docs/mcp.md b/docs/mcp.md index e818579c8..c72ebdd75 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2396,10 +2396,12 @@ 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 -or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`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 `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. +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), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` @@ -2472,7 +2474,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies 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. +`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. `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` diff --git a/docs/revolve.md b/docs/revolve.md index 2ea788f90..fd0f36f16 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,7 +2,10 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, 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. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. @@ -88,13 +91,13 @@ 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 and compound paths only. Type, images and groups must be converted to paths first. +- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. - 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. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. - Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. -- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. +- Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31b606249..af1163095 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1045,6 +1045,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). diff --git a/docs/three-d.md b/docs/three-d.md index f3088cc8d..0e9901061 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -232,8 +232,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. 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 is not built yet. One 3D - effect per object. + 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. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,