Revolve: a group member's blending mode applies to its surface

A member of a group with Revolve drew its surface in Normal mode whatever its own blending mode or that of a group it is in. Each mode now applies once, to the member's surface or to the surfaces of the group that has it, nested as the groups are. Expand Appearance keeps every face of a blended member.
This commit is contained in:
dotCooCoo
2026-11-09 18:05:17 -08:00
parent cddebdf7f6
commit d8d73495bb
3 changed files with 95 additions and 20 deletions
+67 -7
View File
@@ -2,8 +2,8 @@
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::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{PathData, Rect};
use vectorcraft_three_d::{Edge, Revolve};
@@ -431,6 +431,36 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
Some(art)
}
/// The members' `surfaces` in order, from the blending mode at `level` of each one's
/// `Profile::blends` inward: the surfaces of consecutive members that share a group with a mode
/// other than Normal go in one group with that mode, so each mode applies once, at its own level.
fn blended(id: NodeId, surfaces: Vec<(&[(usize, BlendMode)], Node)>, level: usize) -> Vec<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[(usize, BlendMode)], Node)>, key: Option<(usize, BlendMode)>, level: usize, out: &mut Vec<Arc<Node>>) {
if let Some((_, blend)) = key {
let mut group = Node::group(id, blended(id, std::mem::take(run), level + 1));
group.blend = blend;
out.push(Arc::new(group));
}
}
let mut out = vec![];
let mut run = vec![];
let mut key = None;
for (blends, s) in surfaces {
let at = blends.get(level).copied();
if at != key {
close(id, &mut run, key, level, &mut out);
key = at;
}
if key.is_some() {
run.push((blends, s));
} else {
out.push(Arc::new(s));
}
}
close(id, &mut run, key, 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.
@@ -449,16 +479,19 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
let Some(item) = surface_item(&s.items) else { continue };
let (paint, opacity, blend) = paint_of(item);
let opacity = opacity * s.opacity;
opaque &= opacity >= 1.0 && blend == Default::default();
surfaces.push(surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?);
opaque &= opacity >= 1.0 && blend == Default::default() && s.blends.is_empty();
// A member with non-normal blending keeps every face, as a blended object does.
let expand = expand && s.blends.is_empty();
let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?;
surfaces.push((depths, art, s.blends.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 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 {
for (depths, s, _) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
@@ -467,7 +500,7 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
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 {
surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect()
blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, 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();
@@ -717,6 +750,33 @@ mod tests {
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() {
let blend = |n: Arc<Node>, 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::<Vec<_>>(),
_ => 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");
}
#[test]
fn a_3d_effect_on_a_groups_fill_is_refused() {
let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]);
+27 -12
View File
@@ -225,12 +225,16 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul
}
/// 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).
/// it, the opacity of the objects it is in (1 for the object with the effect itself) and their
/// blending modes other than Normal (none for the object itself). `blends` runs from the outermost
/// group in the effect's group down to the member itself, each with a key that the members of
/// one group share. Group Revolve draws the surfaces in those modes.
pub(crate) struct Profile {
pub(crate) path: PathData,
pub(crate) rule: FillRule,
pub(crate) items: Vec<AppearanceItem>,
pub(crate) opacity: f32,
pub(crate) blends: Vec<(usize, BlendMode)>,
}
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
@@ -256,14 +260,25 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000;
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
/// than each about its own. An error names what the group holds that can't become a solid.
pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec<Profile>) -> Result<(), String> {
struct Walk<'a> {
label: &'a str,
out: Vec<Profile>,
keys: usize,
}
fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], 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 blends = blends.to_vec();
if c.blend != BlendMode::Normal {
w.keys += 1;
blends.push((w.keys, c.blend));
}
if is_container(c) {
walk(c, opacity, label, out)?;
walk(c, opacity, &blends, w)?;
continue;
}
let Some((path, rule)) = profile(c) else {
@@ -276,19 +291,19 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, 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, blends });
}
}
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
@@ -524,7 +539,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
let remaining: Vec<Effect>;
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, blends: vec![] }])?);
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
@@ -540,7 +555,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
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, blends: 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);
@@ -703,14 +718,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidM
let paints = solid_paints(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)?
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, blends: vec![] }], total, limit)?
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?);
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, blends: vec![] }], total, limit)?);
}
}
out
+1 -1
View File
@@ -51,7 +51,7 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err());
assert_eq!(out.len(), 1);
// Building past the budget stops with an error instead of holding every mesh.
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, blends: vec![] }];
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
let mut total = 0;
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok());