Revolve: a nested group's opacity applies once to its members together

The opacity of a group inside a revolved group was multiplied into each member's surface, so where members overlap they drew darker than the group does without the effect. A member or group with opacity below 100% or a blending mode other than Normal now holds its members' surfaces in one group with that opacity and mode.
This commit is contained in:
dotCooCoo
2026-11-09 18:30:41 -08:00
parent c770e3bfea
commit 2e81086c38
3 changed files with 60 additions and 41 deletions
+35 -25
View File
@@ -2,12 +2,12 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::color::{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};
use crate::solids::{Profile, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds};
use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
@@ -431,33 +431,34 @@ 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;
/// 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<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option<Scope>, level: usize, out: &mut Vec<Arc<Node>>) {
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 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;
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 key.is_some() {
run.push((blends, s));
if scope.is_some() {
run.push((scopes, s));
} else {
out.push(Arc::new(s));
}
}
close(id, &mut run, key, level, &mut out);
close(id, &mut run, scope, level, &mut out);
out
}
@@ -477,13 +478,13 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
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);
let opacity = opacity * s.opacity;
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();
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.blends.as_slice()));
surfaces.push((depths, art, s.scopes.as_slice()));
}
if surfaces.is_empty() {
return None;
@@ -500,7 +501,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 {
blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, s)).collect(), 0)
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();
@@ -752,6 +753,7 @@ mod tests {
#[test]
fn blending_modes_apply_once_to_the_member_or_group_that_has_them() {
use vectorcraft_doc::color::BlendMode;
let blend = |n: Arc<Node>, mode: BlendMode| {
let mut n = (*n).clone();
n.blend = mode;
@@ -775,6 +777,14 @@ mod tests {
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::<Vec<_>>();
assert_eq!(children.iter().map(|c| c.opacity).collect::<Vec<_>>(), [1.0, 0.5]);
assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together");
}
#[test]
+24 -15
View File
@@ -224,17 +224,26 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul
})
}
/// 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, 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.
/// 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<AppearanceItem>,
pub(crate) opacity: f32,
pub(crate) blends: Vec<(usize, BlendMode)>,
pub(crate) scopes: Vec<Scope>,
}
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
@@ -265,20 +274,20 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
out: Vec<Profile>,
keys: usize,
}
fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], w: &mut Walk) -> Result<(), String> {
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 blends = blends.to_vec();
if c.blend != BlendMode::Normal {
let mut scopes = scopes.to_vec();
if c.blend != BlendMode::Normal || c.opacity < 1.0 {
w.keys += 1;
blends.push((w.keys, c.blend));
scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend });
}
if is_container(c) {
walk(c, opacity, &blends, w)?;
walk(c, opacity, &scopes, w)?;
continue;
}
let Some((path, rule)) = profile(c) else {
@@ -296,7 +305,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
}
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
if path.bounds().is_some() {
w.out.push(Profile { path, rule, items: solid_paints(c), opacity, blends });
w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes });
}
}
Ok(())
@@ -539,7 +548,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, blends: vec![] }])?);
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
@@ -555,7 +564,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, blends: vec![] }])? {
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);
@@ -718,14 +727,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, blends: vec![] }], total, limit)?
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: 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, blends: vec![] }], total, limit)?);
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: 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, blends: vec![] }];
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, scopes: 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());