diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 109e04c3f8..f987e28146 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -2393,6 +2393,7 @@ impl DocumentMessageHandler { let has_fill = !matches!(style.fill, Fill::None); // `style.stroke` is `Some` whenever a `Stroke` node is in the chain, even with weight 0 or a transparent color. // So `is_some()` would treat invisibly-stroked fill-only layers as having a stroke. + // FIXME: Consider if we need to check ATTR_STROKE_PAINT_GRAPHIC let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()); // No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip. diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 1bfc8195bf..09716a957b 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,6 +1,8 @@ +use std::borrow::Cow; + use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; -use core_types::list::{Item, List}; +use core_types::list::{ATTR_STROKE_PAINT_GRAPHIC, Item, List}; use core_types::ops::ListConvert; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; @@ -357,7 +359,16 @@ impl Graphic { Graphic::Vector(vector) => (0..vector.len()).all(|index| { let Some(element) = vector.element(index) else { return false }; let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.); - opacity > 1. - f64::EPSILON && element.style.fill().is_opaque() && element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) + let stroke_paint_graphic_list = vector + .attribute::>(ATTR_STROKE_PAINT_GRAPHIC, index) + .filter(|list| !list.is_empty()) + .map(Cow::Borrowed) + .or_else(|| color_to_graphic_list(element.style.stroke().and_then(|s| s.color())).map(Cow::Owned)); + let stroke_paint_graphic = stroke_paint_graphic_list.as_ref().and_then(|l| l.element(0)); + + opacity > 1. - f64::EPSILON + && element.style.fill().is_opaque() + && (element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || stroke_paint_graphic.is_none_or(|graphic| graphic.is_fully_transparent())) }), _ => false, } @@ -370,6 +381,15 @@ impl Graphic { _ => false, } } + + pub fn is_fully_transparent(&self) -> bool { + match self { + Self::Color(list) => list.element(0).is_some_and(|c| c.a() == 0.), + Self::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), + // FIXME: Write recursive check for other types + _ => false, + } + } } impl BoundingBox for Graphic { diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index b641e0bd2f..df783d9006 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -12,6 +12,28 @@ use std::fmt::Write; use vector_types::GradientStops; use vector_types::gradient::GradientSpreadMethod; +#[derive(Copy, Clone)] +pub enum PaintTarget { + Fill, + Stroke, +} + +impl PaintTarget { + fn paint_attr(self) -> &'static str { + match self { + Self::Fill => "fill", + Self::Stroke => "stroke", + } + } + + fn opacity_attr(self) -> &'static str { + match self { + Self::Fill => "fill-opacity", + Self::Stroke => "stroke-opacity", + } + } +} + pub trait RenderExt { type Output; #[allow(clippy::too_many_arguments)] @@ -24,6 +46,7 @@ pub trait RenderExt { bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams, + target: PaintTarget, ) -> Self::Output; } @@ -39,12 +62,13 @@ impl RenderExt for List { _bounds: DAffine2, _transformed_bounds: DAffine2, _render_params: &RenderParams, + target: PaintTarget, ) -> Self::Output { let Some(color) = self.element(0) else { return r#" fill="none""#.to_string() }; - let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex()); + let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); if color.a() < 1. { - let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.); + let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.); } result @@ -64,6 +88,7 @@ impl RenderExt for List { bounds: DAffine2, transformed_bounds: DAffine2, _render_params: &RenderParams, + _target: PaintTarget, ) -> Self::Output { let mut stop = String::new(); @@ -136,7 +161,7 @@ impl RenderExt for List { impl RenderExt for Stroke { type Output = String; - /// Provide the SVG attributes for the stroke. + /// Provide the shape-related SVG attributes for the stroke. The paint-related attributes for the stroke are generated from `List.render` with `PaintTarget::Stroke`. fn render( &self, _svg_defs: &mut String, @@ -146,9 +171,9 @@ impl RenderExt for Stroke { _bounds: DAffine2, _transformed_bounds: DAffine2, render_params: &RenderParams, + _target: PaintTarget, ) -> Self::Output { // Don't render a stroke at all if it would be invisible - let Some(color) = self.color else { return String::new() }; if !self.has_renderable_stroke() { return String::new(); } @@ -166,10 +191,7 @@ impl RenderExt for Stroke { let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow); // Render the needed stroke attributes - let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex()); - if color.a() < 1. { - let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } + let mut attributes = String::new(); if let Some(mut weight) = weight { if stroke_align.is_some() && render_params.aligned_strokes { weight *= 2.; @@ -210,21 +232,23 @@ impl RenderExt for List { bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams, + target: PaintTarget, ) -> Self::Output { let fill_graphic = self.element(0); + let paint_attr = target.paint_attr(); match fill_graphic { - Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params), + Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target), Some(Graphic::Gradient(gradient_list)) => { - let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params); - format!(r##" fill="url(#{gradient_id})""##) + let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target); + format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => { render_svg_fill_pattern(svg_defs, self, item_transform, bounds, render_params) - .map(|id| format!(r##" fill="url(#{id})""##)) - .unwrap_or_else(|| r#" fill="none""#.to_string()) + .map(|id| format!(r##" {paint_attr}="url(#{id})""##)) + .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)) } - None => r#" fill="none""#.to_string(), + None => format!(r#" {paint_attr}="none""#), } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 197161e5a3..13c03f63f9 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,4 +1,4 @@ -use crate::render_ext::RenderExt; +use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; use core_types::blending::BlendMode; @@ -6,7 +6,7 @@ use core_types::bounds::BoundingBox; use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; -use core_types::list::{ATTR_FILL_GRAPHIC, Item, List}; +use core_types::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, Item, List}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; use core_types::transform::Footprint; @@ -18,7 +18,7 @@ use core_types::{ use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; -use graphic_types::graphic::fill_to_graphic_list; +use graphic_types::graphic::{color_to_graphic_list, fill_to_graphic_list}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; use graphic_types::vector_types::subpath::Subpath; @@ -367,6 +367,7 @@ fn emit_svg_fill_path( bounds_matrix, transformed_bounds_matrix, render_params, + PaintTarget::Fill, ) }) .unwrap_or_else(|| r#" fill="none""#.to_string()); @@ -1004,8 +1005,17 @@ impl Render for List { .or_else(|| fill_to_graphic_list(vector.style.fill()).map(Cow::Owned)); let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); + let stroke_paint_graphic_list = self + .attribute::>(ATTR_STROKE_PAINT_GRAPHIC, index) + .filter(|list| !list.is_empty()) + .map(Cow::Borrowed) + .or_else(|| color_to_graphic_list(vector.style.stroke().and_then(|s| s.color())).map(Cow::Owned)); + let stroke_paint_graphic = stroke_paint_graphic_list.as_ref().and_then(|l| l.element(0)); + let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); - let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); + let can_draw_aligned_stroke = path_is_closed + && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) + && stroke_paint_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.is_opaque()) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; @@ -1098,22 +1108,48 @@ impl Render for List { render_params.aligned_strokes = can_draw_aligned_stroke; render_params.override_paint_order = override_paint_order; - let stroke_attribute = vector + let stroke_shape_attribute = vector .style .stroke() .map(|stroke| { - stroke.render( - defs, - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - &render_params, - ) + if stroke_paint_graphic_list.as_ref().and_then(|l| l.element(0)).is_some() { + stroke.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + PaintTarget::Stroke, + ) + } else { + String::new() + } }) .unwrap_or_default(); + // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback + let stroke_paint_attribute = if vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()) { + stroke_paint_graphic_list + .as_deref() + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + PaintTarget::Stroke, + ) + }) + .unwrap_or_else(|| r#" stroke="none""#.to_string()) + } else { + String::new() + }; + let fill_attribute = if needs_separate_alignment_fill || use_face_fill { r#" fill="none""#.to_string() } else { @@ -1128,6 +1164,7 @@ impl Render for List { bounds_matrix, transformed_bounds_matrix, &render_params, + PaintTarget::Fill, ) }) .unwrap_or_else(|| r#" fill="none""#.to_string()) @@ -1138,7 +1175,8 @@ impl Render for List { attributes.push(mask_type.to_attribute(), selector); } attributes.push_val(fill_attribute); - attributes.push_val(stroke_attribute); + attributes.push_val(stroke_shape_attribute); + attributes.push_val(stroke_paint_attribute); if vector.is_branching() && !use_face_fill { attributes.push("fill-rule", "evenodd"); @@ -1218,6 +1256,7 @@ impl Render for List { // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. let stroke = element.style.stroke(); + // FIXME: Need to add Graphic.is_fully_transparent check let can_draw_aligned_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 8ef5ddb709..7fc55d200c 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -573,7 +573,7 @@ impl Stroke { } pub fn has_renderable_stroke(&self) -> bool { - self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.) + self.weight > 0. } }