diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b31f7ca71..a5004ab99 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -79,10 +79,10 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { } /// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group -/// revolve the art they show ([`resolve`]). +/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let n = &*resolve(doc, n, "Revolve")?; + let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); @@ -329,7 +329,7 @@ pub fn revolve_art(n: &Node) -> Option { } /// [`revolve_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone -/// or in a group, revolve the art they show ([`resolve`]). `None` too when that art can't be +/// or in a group, revolve the art they show ([`resolve_for`]). `None` too when that art can't be /// resolved. pub fn revolve_art_in(doc: Option<&Document>, n: &Node) -> Option { art(doc, n, false) @@ -413,7 +413,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let n = &*resolve(doc, n, "Revolve").ok()?; + let n = &*resolve_for(doc, n, "Revolve", false).ok()?; if is_container(n) { return group_art(n, expand); } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 06461f8de..05d2da4e7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -18,7 +18,7 @@ mod classic; pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; -pub(crate) use source::resolve; +pub(crate) use source::{resolve, resolve_for}; pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; @@ -216,7 +216,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); - let n = &*resolve(doc, n, label)?; + let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 0c69189cb..9ab348f4a 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -52,12 +52,21 @@ fn needs(n: &Node) -> bool { /// is a symbol instance, a blend or a clipping group, otherwise a group with `n`'s id, appearance /// and transparency holding that art (a symbol instance's symbol art from `doc`). An error names /// what can't be resolved: an image, a missing symbol, art nested or numbering past the limits, or -/// a clipping path too complex to cut the art with. `label` is the effect's name. +/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut +/// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result, String> { + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + resolve_for(doc, n, label, every_fill) +} + +/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every +/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the +/// effect builds from the top fill, and the fills cut go together in the top fill's place. +pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result, String> { if !needs(n) { return Ok(Cow::Borrowed(n)); } - let mut r = Resolver { doc, label, leaves: 0, work: 0, shown: None }; + let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None }; let children = r.content(n, 0); #[cfg(test)] WORK.with(|w| w.set(r.work)); @@ -75,6 +84,8 @@ thread_local! { struct Resolver<'a> { doc: Option<&'a Document>, label: &'a str, + /// Does each fill a clipping path cuts become a piece of its own ([`resolve_for`])? + every_fill: bool, /// The objects other than groups and layers kept so far, counted against [`MAX_MEMBERS`]. leaves: usize, /// The objects visited or made so far, counted against [`MAX_WORK`]. @@ -268,10 +279,13 @@ impl Resolver<'_> { let Some((path, rule)) = profile(n) else { return Ok(Some(n.clone())) }; let items = solid_paints(n); // The fills' piece goes where the top fill is, the one Extrude & Bevel and Inflate build - // their solid from, so the strokes above and below it keep their places. + // their solid from, so the strokes above and below it keep their places. Rotate turns + // every fill, so each fill is a piece in its own place. let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + let fill_pieces = + if self.every_fill { items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() } else { usize::from(top_fill.is_some()) }; // Each cut makes a piece, counted before it is made: the shape was counted once already. - let made = usize::from(top_fill.is_some()) + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + let made = fill_pieces + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); self.spend(made)?; self.leaves = self.leaves.saturating_add(made.saturating_sub(1)); if self.leaves > MAX_MEMBERS { @@ -284,12 +298,19 @@ impl Resolver<'_> { Ok(inside.bounds().is_some().then_some(inside)) }; let fills: Vec = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect(); + // Every fill paints the same area: it is cut once. + let mut fill_area: Option> = None; let mut pieces = vec![]; for (k, item) in items.iter().enumerate() { match item { - AppearanceItem::Fill(_) if Some(k) == top_fill => { - if let Some(area) = cut(&path, rule)? { - pieces.push(Arc::new(shape(n, area, FillRule::NonZero, fills.clone()))); + AppearanceItem::Fill(_) if self.every_fill || Some(k) == top_fill => { + let area = match &fill_area { + Some(area) => area.clone(), + None => fill_area.insert(cut(&path, rule)?).clone(), + }; + if let Some(area) = area { + let painted = if self.every_fill { vec![item.clone()] } else { fills.clone() }; + pieces.push(Arc::new(shape(n, area, FillRule::NonZero, painted))); } } AppearanceItem::Fill(_) => {} diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 57828c1df..ab97bdb51 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -146,7 +146,24 @@ fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() { let colors = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), EXTRUDE, json!({}))).unwrap().iter().map(|m| m.color).collect::>(); let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's"); - assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered)), plain); + assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered.clone())), plain); + // Rotate turns both fills and the stroke, each in its own place. + let turned = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), ROTATE, front())).unwrap().iter().map(|m| m.color).collect::>(); + let plain = turned(&Node::group(NodeId(10), vec![Arc::new(layered.clone())])); + assert_eq!(plain.len(), 3, "both fills' surfaces and the stroke's"); + let clipped = clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered); + assert_eq!(turned(&clipped), plain); + assert_eq!(validate_three_d_in(Some(&d), &clipped, ROTATE, &json!({})), Ok(())); + // Each fill Rotate turns counts against the limit on objects; another effect takes the top + // fill only, whatever 3D effect the object carries before it changes type. + let mut many = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0); + many.appearance = + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); MAX_MEMBERS + 1], ..Default::default() }.into(); + let rotated = with(clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), many), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &rotated, ROTATE, &json!({})).is_err_and(|e| e.contains("takes at most"))); + for id in [EXTRUDE, crate::REVOLVE] { + assert_eq!(validate_three_d_in(Some(&d), &rotated, id, &json!({})), Ok(()), "Rotate changed to {id}"); + } // Art nested past the limit inside a clipping group is refused, not followed to any depth. let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0); for i in 0..200 {