From 5f7d4d39229112ec08445ff6e0687bfc792350c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:27:38 -0800 Subject: [PATCH] 3D and Materials: graphics take the opacity mask of a cap in a transparent gradient Under a colored light a surface in a gradient with transparent stops carries an opacity mask. A graphic drawn on such a cap now takes that mask, and each such solid gets a graphic of its own, so a graphic is hidden where its cap is. --- crates/effects/src/solids.rs | 35 ++++++++++++++++----- crates/effects/src/solids/tests_graphics.rs | 19 +++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 07e0d4bbbe..94067c3932 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -844,13 +844,20 @@ fn graphic_art( } /// A solid (a fill's or a stroke's) with ends the viewer sees: the index of its surface, its -/// member, the ends, and the opacity and blending mode of its fill or stroke. +/// member, the ends, the opacity and blending mode of its fill or stroke, and whether its surface +/// carries an opacity mask (a gradient's transparency under a colored light, [`surface`]). struct Filled { at: usize, member: usize, caps: Vec, opacity: f32, blend: BlendMode, + masked: bool, +} + +/// Does a surface in `paint` carry an opacity mask ([`surface`])? +fn masks_surface(paint: &Paint, colored: bool) -> bool { + colored && crate::lighting::opaque_paint(paint).1.is_some() } /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is @@ -868,8 +875,11 @@ fn placed_graphics( let mut groups: Vec> = vec![]; for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their - // own, each drawn as its fill or stroke is. - let joins = |first: &Filled| (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend; + // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask + // gets its own too, which takes that mask ([`with_graphics`]). + let joins = |first: &Filled| { + (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked + }; match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { Some(g) => g.push(f), None => groups.push(vec![f]), @@ -900,12 +910,20 @@ fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFac } /// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each -/// right after its surface, with that surface's scopes. +/// right after its surface, with that surface's scopes and its opacity mask (a gradient's +/// transparency), so a graphic is hidden where its cap is. fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { + let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| (scopes, art.clone()))); + out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + let mut art = art.clone(); + if let Some(mask) = &mask { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); + } + (scopes, art) + })); } out } @@ -943,7 +961,7 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile if graphics_on { let caps: Vec = seen_ends(&f).cloned().collect(); if !caps.is_empty() { - filled.push(Filled { at: out.len(), member, caps, opacity, blend }); + filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) }); } } let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; @@ -991,8 +1009,9 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .enumerate() .filter_map(|(at, (item, member, _, f, shape))| { let caps: Vec = seen_ends(f).cloned().collect(); - let (_, opacity, blend) = paint_of(item, *member); - (!caps.is_empty()).then_some(Filled { at, member: *shape, caps, opacity, blend }) + let (paint, opacity, blend) = paint_of(item, *member); + let masked = masks_surface(&paint, colored); + (!caps.is_empty()).then_some(Filled { at, member: *shape, caps, opacity, blend, masked }) }) .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 96a491bdfc..641c047abb 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -450,6 +450,25 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { art.walk(&mut |c| masked |= c.extras().mask.is_some()); assert!(masked); assert_eq!(graphics_in(&art).len(), 1); + // Each graphic clip group in `art`, in drawing order: whether it carries an opacity mask. + let masks = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && matches!(inner.kind, NodeKind::Group { clip: false, .. }) + { + out.push(c.extras().mask.is_some()); + } + }); + out + }; + // The graphic carries the cap's mask, so it is hidden where the gradient is transparent. + assert_eq!(masks(&art), vec![true]); + // With an opaque stroke drawn after the fill, the fill's cap keeps a masked graphic of its own + // and the stroke's outline gets an unmasked one. + n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0))); + assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); } #[test]