From 6ae93b1222c0accf8bd91abb1821685ba81c83b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:12:15 -0800 Subject: [PATCH 1/2] 3D effects: Extrude & Bevel, Inflate and Rotate on a group keep each member's blending mode On a group, these effects drew every member's solids in Normal mode: only an opacity mask on a member or on a group inside the 3D group was applied to the solids it holds. A member's blending mode, and that of a group inside the 3D group, now apply the same way, to the solids of the members it holds together. Group Revolve already did this. --- crates/effects/src/solids.rs | 22 +++++++---- crates/effects/src/solids/tests_sources.rs | 43 ++++++++++++++++++++++ docs/three-d.md | 7 ++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3c433d344..95ef265cc 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -275,23 +275,30 @@ impl Scope { } } +/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a +/// blending mode other than Normal? +fn composites(scope: &Scope) -> bool { + scope.mask.is_some() || scope.blend != BlendMode::Normal +} + /// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity -/// masks of those scopes, so each mask applies to the faces of the members it holds. The members' -/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque. +/// masks and blending modes of those scopes, so each applies to the faces of the members it holds. +/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are +/// opaque. fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { let masks: Vec<(Vec, Arc)> = surfaces .into_iter() .map(|(scopes, s)| { - let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect(); + let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect(); (masks, s) }) .collect(); crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0) } -/// Does any of `shapes` sit in a scope with an opacity mask? +/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal? fn has_masks(shapes: &[Profile]) -> bool { - shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some())) + shapes.iter().any(|s| s.scopes.iter().any(composites)) } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint @@ -550,8 +557,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); -/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of -/// a group it is in, masks the surfaces of the members it holds ([`masked`]). +/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and +/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds +/// ([`masked`]). fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 2973b7113..51c783452 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,6 +523,49 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } +/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { + let inside = inside || (n.children().is_some() && n.blend == mode); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), + None => { + out.0 += 1; + out.1 += usize::from(inside); + } + } + } + let mut out = (0, 0); + walk(art, false, mode, &mut out); + out +} + +#[test] +fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_solids() { + let d = document(); + let mode = |mut n: Node, mode: BlendMode| { + n.blend = mode; + n + }; + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let member = || rect(1, 100.0, 100.0, 150.0, 200.0, 2.0); + let cases = [ + ("a multiplied member", Node::group(NodeId(90), vec![Arc::new(mode(member(), BlendMode::Multiply)), other()])), + ( + "a member of a multiplied group", + Node::group(NodeId(90), vec![Arc::new(mode(Node::group(NodeId(80), vec![Arc::new(member())]), BlendMode::Multiply)), other()]), + ), + ]; + for (what, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); + assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); + assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + } + } +} + #[test] fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { let mut d = document(); diff --git a/docs/three-d.md b/docs/three-d.md index e6dc279da..635c1285e 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -122,11 +122,12 @@ them all. The members stay editable: moving or editing one reshapes the solids. - Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and - strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. An opacity mask on + strokes, are not drawn. A member's opacity and blending mode apply to its solids, and the blending mode of a group + inside the 3D group applies to the solids of that group's members together. An opacity mask on a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. -- When a member, or a group inside the 3D group, has an opacity mask, each solid is drawn on its own, the backmost - member's first, as when a solid is transparent. +- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each + solid is drawn on its own, the backmost member's first, as when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. - Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a From 478d86cb5fd175720335827ab254e1059564e3b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:38:55 -0800 Subject: [PATCH 2/2] 3D effects: test that only the blended member's faces are drawn in its mode The blending-mode test for a member of a 3D group now checks that every face of the multiplied member, and no face of the other member, is drawn inside the Multiply group, telling the members apart by height. Before, it only checked that some faces were multiplied and some were not, so it also passed when the mode went to the wrong member. --- crates/effects/src/solids/tests_sources.rs | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 51c783452..b2f6daf1c 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,19 +523,17 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } -/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. -fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { - fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { +/// The leaf faces of `art` that draw something, each with its visual bounds and whether it is drawn +/// inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> Vec<(Rect, bool)> { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut Vec<(Rect, bool)>) { let inside = inside || (n.children().is_some() && n.blend == mode); match n.children() { Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), - None => { - out.0 += 1; - out.1 += usize::from(inside); - } + None => out.extend(n.visual_bounds().map(|b| (b, inside))), } } - let mut out = (0, 0); + let mut out = Vec::new(); walk(art, false, mode, &mut out); out } @@ -559,9 +557,14 @@ fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_soli for (what, g) in cases { for id in TYPES { let g = with(g.clone(), id, json!({})); - let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); - assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); - assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + // The member spans y 100 to 200 and the other member y 600 to 650, and every type keeps + // its faces near those heights (Revolve turns them around a vertical axis): a face whose + // center is above y 400 belongs to the member. + let (mine, others): (Vec<_>, Vec<_>) = + faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply).into_iter().partition(|(b, _)| b.center().y < 400.0); + assert!(!mine.is_empty() && !others.is_empty(), "{id}: both members draw faces ({} and {})", mine.len(), others.len()); + assert!(mine.iter().all(|(_, m)| *m), "{id}: every face of {what} is drawn in Multiply"); + assert!(others.iter().all(|(_, m)| !m), "{id}: no face of the other member is drawn in Multiply"); } } }