diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 6cc985332b..e78630f7dd 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -533,13 +533,21 @@ impl Graphic { } Graphic::RasterCPU(_) | Graphic::RasterCPUList(_) => false, Graphic::RasterGPU(_) | Graphic::RasterGPUList(_) => false, - Graphic::Color(item) => item.element().is_opaque(), - Graphic::ColorList(list) => list.element(0).is_some_and(|color| color.is_opaque()), + Graphic::Color(item) => item_opacity_is_full(item) && item.element().is_opaque(), + Graphic::ColorList(list) => !list.is_empty() && every_item_has_full_opacity(list) && list.iter_element_values().all(|color| color.is_opaque()), // A `Clear` spread cuts off to transparency past the ends, leaving the rest of the region unpainted - Graphic::Gradient(item) => item.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD) != GradientSpread::Clear && item.element().iter().all(|stop| stop.color.is_opaque()), + Graphic::Gradient(item) => { + item_opacity_is_full(item) + && item.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD) != GradientSpread::Clear + && item.element().iter().all(|stop| stop.color.is_opaque()) + } Graphic::GradientList(list) => { - list.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD, 0) != GradientSpread::Clear - && list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())) + !list.is_empty() + && every_item_has_full_opacity(list) + && (0..list.len()).all(|index| { + list.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD, index) != GradientSpread::Clear + && list.element(index).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())) + }) } Graphic::Text(_) | Graphic::TextList(_) => false, } @@ -562,11 +570,11 @@ impl Graphic { list.attribute::(ATTR_APPEARANCE, index), ) }), - Graphic::Color(item) => item.element().a() == 0., - Graphic::ColorList(list) => list.iter_element_values().all(|color| color.a() == 0.), + Graphic::Color(item) => item_opacity_is_zero(item) || item.element().a() == 0., + Graphic::ColorList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|color| color.a() == 0.), // A stopless ramp paints as solid black, matching `Gradient::evaluate`, so it counts as transparent only once it has stops - Graphic::Gradient(item) => !item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.), - Graphic::GradientList(list) => list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)), + Graphic::Gradient(item) => item_opacity_is_zero(item) || (!item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.)), + Graphic::GradientList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)), // Their content is never inspected, so zeroed opacity is the only invisibility these can report Graphic::RasterCPU(item) => item_opacity_is_zero(item), Graphic::RasterGPU(item) => item_opacity_is_zero(item), diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 6feee87264..0bea6f6cda 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -50,15 +50,19 @@ pub trait RenderExt { ) -> Self::Output; } -/// The paint attribute for a solid color, or `none` when the color is absent. -fn render_color_paint(color: Option<&Color>, target: PaintTarget) -> String { - let Some(color) = color else { - return format!(r#" {}="none""#, target.paint_attr()); - }; +/// The paint attribute for a solid color, or the SVG `none` keyword when the color is absent. +/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains. +fn render_color_paint(item: Option>, target: PaintTarget, for_mask: bool) -> String { + let unpainted = || format!(r#" {}="none""#, target.paint_attr()); + + let Some(item) = item else { return unpainted() }; + let Some(color) = item.element() else { return unpainted() }; + + let alpha = color.a() * item.paint_opacity(for_mask); let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); - if color.a() < 1. { - let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.); + if alpha < 1. { + let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (alpha * 1000.).round() / 1000.); } result @@ -74,15 +78,16 @@ impl RenderExt for List { _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _render_params: &RenderParams, + render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { - render_color_paint(self.element(0), target) + render_color_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), target, render_params.for_mask) } } /// Adds one gradient item's def into `svg_defs` and returns the gradient ID, or `None` when the item is absent. -fn render_gradient_paint(item: Option>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> Option { +/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains. +fn render_gradient_paint(item: Option>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2, for_mask: bool) -> Option { let mut stop = String::new(); let item = item?; @@ -91,7 +96,14 @@ fn render_gradient_paint(item: Option>, svg_defs: &mut Str let local_gradient_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let settings = gradient_settings_from_item(item); - let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); + let (mut samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); + + let paint_opacity = item.paint_opacity(for_mask); + if paint_opacity < 1. { + for (_, color, _) in &mut samples { + *color = color.with_alpha(color.a() * paint_opacity); + } + } for (position, color, original_midpoint) in samples { stop.push_str(">, svg_defs: &mut Str // A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec) if stop.is_empty() { - stop.push_str(r##""##); + stop.push_str(r##""); } // Need to cancel out the element's transform as it is already applied to the path itself. @@ -169,10 +185,16 @@ impl RenderExt for List { element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _render_params: &RenderParams, + render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { - render_gradient_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), svg_defs, item_transform, element_transform) + render_gradient_paint( + (!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), + svg_defs, + item_transform, + element_transform, + render_params.for_mask, + ) } } @@ -253,9 +275,9 @@ impl RenderExt for List { let paint_attr = target.paint_attr(); match fill_graphic { - Some(Graphic::Color(item)) => render_color_paint(Some(item.element()), target), + Some(Graphic::Color(item)) => render_color_paint(Some(ItemRef::Item(item)), target, render_params.for_mask), Some(Graphic::ColorList(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target), - Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform) + Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform, render_params.for_mask) .map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##)) .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)), Some(Graphic::GradientList(gradient_list)) => gradient_list diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b21cd78699..407e7f21d5 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -85,6 +85,14 @@ impl<'a, T> ItemRef<'a, T> { } } + /// The alpha multiplier this item's opacity attributes apply when it serves as a paint. + /// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it. + pub(crate) fn paint_opacity(self, for_mask: bool) -> f32 { + let opacity_fill = if for_mask { 1. } else { self.attribute_cloned_or::(ATTR_OPACITY_FILL, 1.) }; + + (self.attribute_cloned_or::(ATTR_OPACITY, 1.) * opacity_fill) as f32 + } + pub(crate) fn clone_item_attributes(self) -> core_types::list::ItemAttributeValues { match self { ItemRef::ListItem(list, index) => list.clone_item_attributes(index), @@ -577,14 +585,28 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { } } -fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { +/// The Vello brush for one gradient item, paired with its placement transform. +/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains. +fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_item.element()?; let gradient_form: GradientForm = gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); let gradient_transform: DAffine2 = gradient_item.attribute_cloned_or_default(ATTR_TRANSFORM); let settings = gradient_settings_from_item(gradient_item); - let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); + let (mut samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); + + let paint_opacity = gradient_item.paint_opacity(for_mask); + if paint_opacity < 1. { + // A stopless ramp gets its black stop downstream, too late to be faded, so it needs one here instead + if samples.is_empty() { + samples.push((0., Color::BLACK, None)); + } + + for (_, color, _) in &mut samples { + *color = color.with_alpha(color.a() * paint_opacity); + } + } let peniko_stops = peniko_color_stops(&samples); @@ -1740,12 +1762,15 @@ fn render_vector_item_to_vello( for paint_index in 0..fill_graphic.len() { let Some(paint) = fill_graphic.element(paint_index) else { continue }; - let solid_fill = |scene: &mut Scene, color: &Color| { - let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + let solid_fill = |scene: &mut Scene, item: ItemRef<'_, Color>| { + let Some(color) = item.element() else { return }; + let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask)); + + let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); }; let gradient_fill = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { return; }; @@ -1760,11 +1785,8 @@ fn render_vector_item_to_vello( match paint { Graphic::None(_) | Graphic::NoneList(_) => continue, - Graphic::Color(item) => solid_fill(scene, item.element()), - Graphic::ColorList(list) => { - let Some(color) = list.element(0) else { continue }; - solid_fill(scene, color); - } + Graphic::Color(item) => solid_fill(scene, ItemRef::Item(item)), + Graphic::ColorList(list) => solid_fill(scene, ItemRef::ListItem(list, 0)), Graphic::Gradient(item) => gradient_fill(scene, ItemRef::Item(item)), Graphic::GradientList(list) => gradient_fill(scene, ItemRef::ListItem(list, 0)), // Any other graphic content paints as a texture clipped to the path @@ -1839,13 +1861,16 @@ fn render_vector_item_to_vello( continue; }; - let solid_stroke = |scene: &mut Scene, color: &Color| { - let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + let solid_stroke = |scene: &mut Scene, item: ItemRef<'_, Color>| { + let Some(color) = item.element() else { return }; + let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask)); + + let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color()); scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); }; let gradient_stroke = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { return; }; let inverse_element_transform = if transform_is_invertible(element_transform) { @@ -1860,11 +1885,8 @@ fn render_vector_item_to_vello( match stroke_graphic { Graphic::None(_) | Graphic::NoneList(_) => continue, - Graphic::Color(item) => solid_stroke(scene, item.element()), - Graphic::ColorList(list) => { - let Some(color) = list.element(0) else { continue }; - solid_stroke(scene, color); - } + Graphic::Color(item) => solid_stroke(scene, ItemRef::Item(item)), + Graphic::ColorList(list) => solid_stroke(scene, ItemRef::ListItem(list, 0)), Graphic::Gradient(item) => gradient_stroke(scene, ItemRef::Item(item)), Graphic::GradientList(list) => gradient_stroke(scene, ItemRef::ListItem(list, 0)), // Any other graphic content paints as a texture clipped to the stroked region