3D: Rotate keeps each clipped fill in its place among the strokes

Inside a clipping group, the fills of a shape were cut into one piece in
the top fill's place. Rotate turns every fill, so with a fill, a stroke
and a fill, the lower fill was drawn above the stroke. With Rotate, each
fill now becomes its own piece in its own place. Extrude & Bevel, Inflate
and Revolve build from the top fill and keep one piece there, also when
an object changes from Rotate to another 3D type.
This commit is contained in:
dotCooCoo
2026-11-10 11:16:25 -08:00
parent ce1940888a
commit d7845e7068
4 changed files with 53 additions and 15 deletions
+5 -5
View File
@@ -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<Node> {
}
/// [`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<Node> {
art(doc, n, false)
@@ -413,7 +413,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
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);
}
+2 -2
View File
@@ -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"))?;
+28 -7
View File
@@ -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<Cow<'a, Node>, String> {
let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE);
resolve_for(doc, n, label, every_fill)
}
/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every
/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the
/// effect builds from the top fill, and the fills cut go together in the top fill's place.
pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result<Cow<'a, Node>, String> {
if !needs(n) {
return Ok(Cow::Borrowed(n));
}
let mut r = Resolver { doc, label, 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<AppearanceItem> = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect();
// Every fill paints the same area: it is cut once.
let mut fill_area: Option<Option<PathData>> = None;
let mut pieces = vec![];
for (k, item) in items.iter().enumerate() {
match item {
AppearanceItem::Fill(_) if 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(_) => {}
+18 -1
View File
@@ -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::<Vec<_>>();
let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())]));
assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's");
assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered)), 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::<Vec<_>>();
let plain = turned(&Node::group(NodeId(10), vec![Arc::new(layered.clone())]));
assert_eq!(plain.len(), 3, "both fills' surfaces and the stroke's");
let clipped = clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered);
assert_eq!(turned(&clipped), plain);
assert_eq!(validate_three_d_in(Some(&d), &clipped, ROTATE, &json!({})), Ok(()));
// Each fill Rotate turns counts against the limit on objects; another effect takes the top
// fill only, whatever 3D effect the object carries before it changes type.
let mut many = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0);
many.appearance =
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); MAX_MEMBERS + 1], ..Default::default() }.into();
let rotated = with(clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), many), ROTATE, front());
assert!(validate_three_d_in(Some(&d), &rotated, ROTATE, &json!({})).is_err_and(|e| e.contains("takes at most")));
for id in [EXTRUDE, crate::REVOLVE] {
assert_eq!(validate_three_d_in(Some(&d), &rotated, id, &json!({})), Ok(()), "Rotate changed to {id}");
}
// Art nested past the limit inside a clipping group is refused, not followed to any depth.
let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0);
for i in 0..200 {