From e192269481f406ab020d923e7dffb0e472918024 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 6 May 2026 17:19:57 +0900 Subject: [PATCH 01/18] Add conversion from Fill to Table --- .../libraries/graphic-types/src/graphic.rs | 21 ++++++++++++- .../libraries/vector-types/src/gradient.rs | 30 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 0efade8880..df0e06b92a 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -4,7 +4,7 @@ use core_types::list::List; use core_types::ops::ListConvert; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; -use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use dyn_any::DynAny; use glam::DAffine2; use raster_types::{CPU, GPU, Raster}; @@ -12,6 +12,7 @@ use vector_types::GradientStops; // use vector_types::Vector; pub use vector_types::Vector; +use vector_types::vector::style::Fill; /// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, CacheHash, PartialEq, DynAny)] @@ -169,6 +170,24 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) output } +/// Converts a `Fill` enum into the `Table` representation used as paint storage. +/// TODO: Remove once all paint sources flow through `Table` directly without going through the `Fill` enum. +pub fn fill_to_paint(fill: &Fill) -> Option> { + match fill { + Fill::None => None, + Fill::Solid(color) => Some(Table::new_from_element((*color).into())), + Fill::Gradient(gradient) => { + let gradient_row = TableRow::new_from_element(gradient.stops.clone()) + .with_attribute(ATTR_TRANSFORM, gradient.to_transform()) + .with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type) + .with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method); + let gradient_table = Table::new_from_row(gradient_row); + + Some(Table::new_from_element(Graphic::Gradient(gradient_table))) + } + } +} + /// Maps from a concrete element type to its corresponding `Graphic` enum variant, /// enabling type-directed casting of typed `List`s from a `Graphic` value. pub trait TryFromGraphic: Clone + Sized { diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9df066e76e..40aaa939e7 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -587,6 +587,12 @@ impl Gradient { Some(index) } + + /// Builds the affine that places the gradient endpoints at `start` and `end` when applied to canonical gradient space (0,0) -> (1,0) + pub fn to_transform(&self) -> DAffine2 { + let direction = self.end - self.start; + DAffine2::from_cols(direction, direction.perp(), self.start) + } } // TODO: Eventually remove this migration document upgrade code @@ -625,3 +631,27 @@ impl core_types::bounds::BoundingBox for GradientStops { core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) } } + +#[cfg(test)] +mod tests { + use super::*; + use glam::DVec2; + + fn linear_gradient(start: DVec2, end: DVec2) -> Gradient { + Gradient { start, end, ..Default::default() } + } + + #[test] + fn to_transform_roundtrip() { + let cases = [(DVec2::ZERO, DVec2::X), (DVec2::new(10., 20.), DVec2::new(50., 30.)), (DVec2::new(-5., -5.), DVec2::new(5., 3.))]; + + for (start, end) in cases { + let transform = linear_gradient(start, end).to_transform(); + let recovered_start = transform.transform_point2(DVec2::ZERO); + let recovered_end = transform.transform_point2(DVec2::X); + + assert!((recovered_start - start).length() < 1e-10); + assert!((recovered_end - end).length() < 1e-10); + } + } +} From 25358d3832b65ddebea0c7ca29f48190157b3402 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 7 May 2026 23:30:56 +0900 Subject: [PATCH 02/18] Refactor Vector vello renderer for Gradient / Color # Conflicts: # node-graph/libraries/rendering/src/renderer.rs --- .../libraries/rendering/src/renderer.rs | 135 ++++++++++-------- 1 file changed, 76 insertions(+), 59 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 6a6ca7c82b..2a0cccfad0 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -18,6 +18,7 @@ use core_types::{ use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; +use graphic_types::graphic::fill_to_paint; 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; @@ -1181,70 +1182,86 @@ impl Render for List { let use_layer = can_draw_aligned_stroke; let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); - // Closures to avoid duplicated fill/stroke drawing logic - let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() { - Fill::Solid(color) => { - 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); - } - Fill::Gradient(gradient) => { - let mut stops = peniko::ColorStops::new(); - for (position, color, _) in gradient.stops.interpolated_samples() { - stops.push(peniko::ColorStop { - offset: position as f32, - color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), - }); - } - - let bounds = element.nonzero_bounding_box(); - let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - - let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { - parent_transform.inverse() - } else { - Default::default() - }; - let mod_points = inverse_parent_transform * multiplied_transform * bound_transform; + // TODO: This conversion is only necessary during the transition period from Fill to Table + let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| { + let Some(paint_table) = fill_to_paint(element.style.fill()) else { + return; + }; - let start = mod_points.transform_point2(gradient.start); - let end = mod_points.transform_point2(gradient.end); + for paint_idx in 0..paint_table.len() { + let Some(paint) = paint_table.element(paint_idx) else { continue }; + match paint { + Graphic::Color(table) => { + let Some(color) = table.element(0) else { continue }; - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind: match gradient.gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { - start: to_point(start), - end: to_point(end), - } - .into(), - GradientType::Radial => { - let radius = start.distance(end); - peniko::RadialGradientPosition { - start_center: to_point(start), - start_radius: 0., - end_center: to_point(start), - end_radius: radius as f32, - } - .into() + 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); + } + Graphic::Gradient(stops_table) => { + let Some(stops) = stops_table.element(0) else { continue }; + let gradient_type: GradientType = stops_table.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_transform: DAffine2 = stops_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = stops_table.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + + let mut peniko_stops = peniko::ColorStops::new(); + for (position, color, _) in stops.interpolated_samples() { + peniko_stops.push(peniko::ColorStop { + offset: position as f32, + color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), + }); } - }, - extend: match gradient.spread_method { - GradientSpreadMethod::Pad => peniko::Extend::Pad, - GradientSpreadMethod::Reflect => peniko::Extend::Reflect, - GradientSpreadMethod::Repeat => peniko::Extend::Repeat, - }, - stops, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, - ..Default::default() - }); - let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { - element_transform.inverse() - } else { - Default::default() + + let bounds = element.nonzero_bounding_box(); + let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + + let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { + parent_transform.inverse() + } else { + Default::default() + }; + let mod_points = inverse_parent_transform * multiplied_transform * bound_transform * gradient_transform; + + let start = mod_points.transform_point2(DVec2::ZERO); + let end = mod_points.transform_point2(DVec2::X); + + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind: match gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { + start: to_point(start), + end: to_point(end), + } + .into(), + GradientType::Radial => { + let radius = start.distance(end); + peniko::RadialGradientPosition { + start_center: to_point(start), + start_radius: 0., + end_center: to_point(start), + end_radius: radius as f32, + } + .into() + } + }, + extend: match spread_method { + GradientSpreadMethod::Pad => peniko::Extend::Pad, + GradientSpreadMethod::Reflect => peniko::Extend::Reflect, + GradientSpreadMethod::Repeat => peniko::Extend::Repeat, + }, + stops: peniko_stops, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); + } + _ => todo!(), }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); } - Fill::None => {} }; // Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering. From 8a2aec61e72dfea5bf106ca940b437485f55f86d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 8 May 2026 22:50:38 +0900 Subject: [PATCH 03/18] Refactor Vector SVG renderer for Gradient / Color --- .../libraries/rendering/src/render_ext.rs | 75 +++++++++++++------ 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 883640d05d..6cbfded901 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,10 +1,15 @@ use crate::renderer::{RenderParams, format_transform_matrix}; +use core_types::table::Table; use core_types::color::SRGBA8; use core_types::uuid::generate_uuid; -use glam::DAffine2; -use graphic_types::vector_types::gradient::{Gradient, GradientType}; +use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; +use glam::{DAffine2, DVec2}; +use graphic_types::Graphic; +use graphic_types::graphic::fill_to_paint; +use graphic_types::vector_types::gradient::GradientType; use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; +use vector_types::GradientStops; use vector_types::gradient::GradientSpreadMethod; pub trait RenderExt { @@ -12,13 +17,42 @@ pub trait RenderExt { fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output; } -impl RenderExt for Gradient { +impl RenderExt for Table { + type Output = String; + + fn render( + &self, + _svg_defs: &mut String, + _element_transform: DAffine2, + _stroke_transform: DAffine2, + _bounds: DAffine2, + _transformed_bounds: DAffine2, + _render_params: &RenderParams, + ) -> Self::Output { + let Some(color) = self.element(0) else { return String::new() }; + + let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex()); + if color.a() < 1. { + let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.); + } + + result + } +} + +impl RenderExt for Table { type Output = u64; /// Adds the gradient def through mutating the first argument, returning the gradient ID. fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, _render_params: &RenderParams) -> Self::Output { let mut stop = String::new(); - for (position, color, original_midpoint) in self.stops.interpolated_samples() { + + let Some(stops) = self.element(0) else { return 0 }; + let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + + for (position, color, original_midpoint) in stops.interpolated_samples() { stop.push_str("") } - let transform_points = element_transform * stroke_transform * bounds; - let start = transform_points.transform_point2(self.start); - let end = transform_points.transform_point2(self.end); + let transform_points = element_transform * stroke_transform * bounds * gradient_transform; + let start = transform_points.transform_point2(DVec2::ZERO); + let end = transform_points.transform_point2(DVec2::X); let gradient_transform = if transformed_bounds.matrix2.determinant() != 0. { transformed_bounds.inverse() @@ -49,15 +83,15 @@ impl RenderExt for Gradient { format!(r#" gradientTransform="{gradient_transform}""#) }; - let spread_method = if self.spread_method == GradientSpreadMethod::Pad { + let spread_method = if spread_method == GradientSpreadMethod::Pad { String::new() } else { - format!(r#" spreadMethod="{}""#, self.spread_method.svg_name()) + format!(r#" spreadMethod="{}""#, spread_method.svg_name()) }; let gradient_id = generate_uuid(); - match self.gradient_type { + match gradient_type { GradientType::Linear => { let _ = write!( svg_defs, @@ -84,19 +118,18 @@ impl RenderExt for Fill { /// Renders the fill, adding necessary defs through mutating the first argument. fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { - match self { - Self::None => r#" fill="none""#.to_string(), - Self::Solid(color) => { - let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex()); - if color.a() < 1. { - let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } - result - } - Self::Gradient(gradient) => { - let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); + let Some(paint_table) = fill_to_paint(self) else { return r#" fill="none""#.to_string() }; + let Some(paint) = paint_table.element(0) else { return String::new() }; + + match paint { + Graphic::Color(color_table) => color_table.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params), + Graphic::Gradient(stops_table) => { + let gradient_id = stops_table.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); format!(r##" fill="url('#{gradient_id}')""##) } + _ => { + todo!() + } } } } From bad70f7be9686c612950b07411825ebb8e01bf95 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 12 May 2026 23:06:49 +0900 Subject: [PATCH 04/18] Fix conflicts --- node-graph/libraries/core-types/src/list.rs | 3 + .../libraries/graphic-types/src/graphic.rs | 16 ++--- .../libraries/rendering/src/render_ext.rs | 18 +++--- .../libraries/rendering/src/renderer.rs | 60 +++++++++++-------- node-graph/nodes/math/src/lib.rs | 27 ++++++++- 5 files changed, 80 insertions(+), 44 deletions(-) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 61f54597b4..e53bc2bcd6 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -77,6 +77,9 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; +/// Table data for fill. +pub const ATTR_FILL_GRAPHIC: &str = "fill_graphic"; + // ======================== // TRAIT: AnyAttributeValue // ======================== diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index df0e06b92a..eebf7e73a6 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,6 +1,6 @@ use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; -use core_types::list::List; +use core_types::list::{Item, List}; use core_types::ops::ListConvert; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; @@ -170,20 +170,20 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) output } -/// Converts a `Fill` enum into the `Table` representation used as paint storage. -/// TODO: Remove once all paint sources flow through `Table` directly without going through the `Fill` enum. -pub fn fill_to_paint(fill: &Fill) -> Option> { +/// Converts a `Fill` enum into the `List` representation used as paint storage. +/// TODO: Remove once all paint sources flow through `List` directly without going through the `Fill` enum. +pub fn fill_to_graphic_list(fill: &Fill) -> Option> { match fill { Fill::None => None, - Fill::Solid(color) => Some(Table::new_from_element((*color).into())), + Fill::Solid(color) => Some(List::new_from_element((*color).into())), Fill::Gradient(gradient) => { - let gradient_row = TableRow::new_from_element(gradient.stops.clone()) + let gradient_row = Item::new_from_element(gradient.stops.clone()) .with_attribute(ATTR_TRANSFORM, gradient.to_transform()) .with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type) .with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method); - let gradient_table = Table::new_from_row(gradient_row); + let gradient_list = List::new_from_item(gradient_row); - Some(Table::new_from_element(Graphic::Gradient(gradient_table))) + Some(List::new_from_element(Graphic::Gradient(gradient_list))) } } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 6cbfded901..9afdb0fc9b 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,11 +1,11 @@ use crate::renderer::{RenderParams, format_transform_matrix}; -use core_types::table::Table; +use core_types::list::List; use core_types::color::SRGBA8; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; use graphic_types::Graphic; -use graphic_types::graphic::fill_to_paint; +use graphic_types::graphic::fill_to_graphic_list; use graphic_types::vector_types::gradient::GradientType; use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; @@ -17,7 +17,7 @@ pub trait RenderExt { fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output; } -impl RenderExt for Table { +impl RenderExt for List { type Output = String; fn render( @@ -40,7 +40,7 @@ impl RenderExt for Table { } } -impl RenderExt for Table { +impl RenderExt for List { type Output = u64; /// Adds the gradient def through mutating the first argument, returning the gradient ID. @@ -118,13 +118,13 @@ impl RenderExt for Fill { /// Renders the fill, adding necessary defs through mutating the first argument. fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { - let Some(paint_table) = fill_to_paint(self) else { return r#" fill="none""#.to_string() }; - let Some(paint) = paint_table.element(0) else { return String::new() }; + let Some(paint_list) = fill_to_graphic_list(self) else { return r#" fill="none""#.to_string() }; + let Some(paint) = paint_list.element(0) else { return String::new() }; match paint { - Graphic::Color(color_table) => color_table.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params), - Graphic::Gradient(stops_table) => { - let gradient_id = stops_table.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); + Graphic::Color(color_list) => color_list.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params), + Graphic::Gradient(stops_list) => { + let gradient_id = stops_list.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); format!(r##" fill="url('#{gradient_id}')""##) } _ => { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 2a0cccfad0..d94453b32d 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -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::{Item, List}; +use core_types::list::{ATTR_FILL_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_paint; +use graphic_types::graphic::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; @@ -1111,7 +1111,7 @@ impl Render for List { } } - fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { use graphic_types::vector_types::vector::style::{GradientType, StrokeCap, StrokeJoin}; for index in 0..self.len() { @@ -1182,26 +1182,32 @@ impl Render for List { let use_layer = can_draw_aligned_stroke; let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); - // TODO: This conversion is only necessary during the transition period from Fill to Table - let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| { - let Some(paint_table) = fill_to_paint(element.style.fill()) else { + let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { + // Try to use ATTR_FILL_GRAPHIC attribute, which is set by `fill_graphic` debug node, then fall back to Fill enum. + // TODO: Drop the Fill fall back once the Fill node becomes ready to store corresponding Graphic list directly. + let Some(fill_graphic) = self + .attribute::>(ATTR_FILL_GRAPHIC, index) + .filter(|t| !t.is_empty()) + .cloned() + .or_else(|| fill_to_graphic_list(element.style.fill())) + else { return; }; - for paint_idx in 0..paint_table.len() { - let Some(paint) = paint_table.element(paint_idx) else { continue }; + for paint_idx in 0..fill_graphic.len() { + let Some(paint) = fill_graphic.element(paint_idx) else { continue }; match paint { - Graphic::Color(table) => { - let Some(color) = table.element(0) else { continue }; + Graphic::Color(list) => { + let Some(color) = list.element(0) else { continue }; 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); } - Graphic::Gradient(stops_table) => { - let Some(stops) = stops_table.element(0) else { continue }; - let gradient_type: GradientType = stops_table.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); - let gradient_transform: DAffine2 = stops_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let spread_method: GradientSpreadMethod = stops_table.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + Graphic::Gradient(stops_list) => { + let Some(stops) = stops_list.element(0) else { continue }; + let gradient_type: GradientType = stops_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_transform: DAffine2 = stops_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = stops_list.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); let mut peniko_stops = peniko::ColorStops::new(); for (position, color, _) in stops.interpolated_samples() { @@ -1259,14 +1265,18 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); } - _ => todo!(), + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => { + scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); + paint.render_to_vello(scene, multiplied_transform, context, render_params); + scene.pop_layer(); + } }; } }; // Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering. let use_face_fill = element.use_face_fill(); - let do_fill = |scene: &mut Scene| { + let do_fill = |scene: &mut Scene, context: &mut RenderContext| { if use_face_fill { for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); @@ -1274,12 +1284,12 @@ impl Render for List { for element in face_path { kurbo_path.push(element); } - do_fill_path(scene, &kurbo_path, peniko::Fill::NonZero); + do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); } } else if element.is_branching() { - do_fill_path(scene, &path, peniko::Fill::EvenOdd); + do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); } else { - do_fill_path(scene, &path, peniko::Fill::NonZero); + do_fill_path(scene, context, &path, peniko::Fill::NonZero); } }; @@ -1349,7 +1359,7 @@ impl Render for List { if wants_stroke_below { scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); do_stroke(scene, 2.); @@ -1357,13 +1367,13 @@ impl Render for List { scene.pop_layer(); scene.pop_layer(); - do_fill(scene); + do_fill(scene, context); } else { // Fill first (unclipped), then stroke (clipped) above - do_fill(scene); + do_fill(scene, context); scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); do_stroke(scene, 2.); @@ -1385,7 +1395,7 @@ impl Render for List { for operation in &order { match operation { - Op::Fill => do_fill(scene), + Op::Fill => do_fill(scene, context), Op::Stroke => do_stroke(scene, 1.), } } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 3e864ee727..43ffa38619 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,11 +1,11 @@ use core_types::Context; -use core_types::list::List; +use core_types::list::{ATTR_FILL_GRAPHIC, List}; use core_types::registry::types::{Fraction, Percentage, PixelSize}; use core_types::transform::Footprint; use core_types::{Color, Ctx, num_traits}; use glam::{DAffine2, DVec2}; use graphic_types::raster_types::{CPU, GPU, Raster}; -use graphic_types::{Artboard, Graphic, Vector}; +use graphic_types::{Artboard, Graphic, IntoGraphicList, Vector}; use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; @@ -985,6 +985,29 @@ fn normalize(_: impl Ctx, vector: DVec2) -> DVec2 { vector.normalize_or_zero() } +/// Sets the `fill_graphic` attribute on each item of the input vector list. +/// Used for testing of clipping-based fill rendering until the proper Fill node refactor lands. +#[node_macro::node(category("Debug"))] +fn fill_graphic( + _: impl Ctx, + mut vectors: List, + #[implementations( + List, + List, + List>, + List>, + List, + List, + )] + fill_graphic: P, +) -> List { + let paint_list = fill_graphic.into_graphic_list(); + for row_idx in 0..vectors.len() { + vectors.set_attribute(ATTR_FILL_GRAPHIC, row_idx, paint_list.clone()); + } + vectors +} + #[cfg(test)] mod test { use super::*; From 7ee4117c37f166a5c38a6127d7849cb5fc5b07d8 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 13 May 2026 17:10:27 +0900 Subject: [PATCH 05/18] Add basic clipping-based fill for SVG rendering --- node-graph/libraries/core-types/src/list.rs | 2 +- .../libraries/rendering/src/render_ext.rs | 41 +- .../libraries/rendering/src/renderer.rs | 374 ++++++++++++++---- 3 files changed, 309 insertions(+), 108 deletions(-) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index e53bc2bcd6..7a7df7d6ca 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -77,7 +77,7 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; -/// Table data for fill. +/// List data for fill. pub const ATTR_FILL_GRAPHIC: &str = "fill_graphic"; // ======================== diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 9afdb0fc9b..7ac21df4f2 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -4,10 +4,8 @@ use core_types::color::SRGBA8; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; -use graphic_types::Graphic; -use graphic_types::graphic::fill_to_graphic_list; use graphic_types::vector_types::gradient::GradientType; -use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::GradientStops; use vector_types::gradient::GradientSpreadMethod; @@ -113,27 +111,6 @@ impl RenderExt for List { } } -impl RenderExt for Fill { - type Output = String; - - /// Renders the fill, adding necessary defs through mutating the first argument. - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { - let Some(paint_list) = fill_to_graphic_list(self) else { return r#" fill="none""#.to_string() }; - let Some(paint) = paint_list.element(0) else { return String::new() }; - - match paint { - Graphic::Color(color_list) => color_list.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params), - Graphic::Gradient(stops_list) => { - let gradient_id = stops_list.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); - format!(r##" fill="url('#{gradient_id}')""##) - } - _ => { - todo!() - } - } - } -} - impl RenderExt for Stroke { type Output = String; @@ -197,19 +174,3 @@ impl RenderExt for Stroke { attributes } } - -impl RenderExt for PathStyle { - type Output = String; - - /// Renders the shape's fill and stroke attributes as a string with them concatenated together. - #[allow(clippy::too_many_arguments)] - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> String { - let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); - let stroke_attribute = self - .stroke - .as_ref() - .map(|stroke| stroke.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params)) - .unwrap_or_default(); - format!("{fill_attribute}{stroke_attribute}") - } -} diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index d94453b32d..f9433f0488 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -330,6 +330,121 @@ fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_p scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path); } +/// Returns true if the resolved fill graphic fully and opaquely covers the path interior. +fn fill_covers_opaquely(fill_graphic: Option<&Graphic>) -> bool { + match fill_graphic { + Some(Graphic::Color(list)) => list.element(0).is_some_and(|c| c.a() >= 1.0), + Some(Graphic::Gradient(list)) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.a() >= 1.0)), + _ => false, + } +} + +/// Returns the fill attribute for SVG tags corresponding to the given fill_graphic. +fn compute_svg_fill_attribute( + fill_graphic: Option<&Graphic>, + defs: &mut String, + element_transform: DAffine2, + applied_stroke_transform: DAffine2, + bounds_matrix: DAffine2, + transformed_bounds_matrix: DAffine2, + render_params: &RenderParams, +) -> String { + match fill_graphic { + Some(Graphic::Color(color_list)) => color_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params), + Some(Graphic::Gradient(gradient_list)) => { + let gradient_id = gradient_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params); + format!(r##" fill="url('#{gradient_id}')""##) + } + _ => r#" fill="none""#.to_string(), + } +} + +/// Emits an SVG `` element with the resolved fill attribute corresponding to the given fill_graphic. +#[allow(clippy::too_many_arguments)] +fn emit_svg_fill_path( + render: &mut SvgRender, + d: String, + element_transform: DAffine2, + fill_graphic: Option<&Graphic>, + applied_stroke_transform: DAffine2, + bounds_matrix: DAffine2, + transformed_bounds_matrix: DAffine2, + render_params: &RenderParams, +) { + render.leaf_tag("path", |attributes| { + attributes.push("d", d); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + let defs = &mut attributes.0.svg_defs; + let fill_attribute = compute_svg_fill_attribute(fill_graphic, defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params); + attributes.push_val(fill_attribute); + }); +} + +/// Emits an SVG `` group that renders the fill graphic clipped to the path referenced by `clip_id`. +fn emit_svg_fill_clip(render: &mut SvgRender, clip_id: &str, fill_graphic_list: &List, item_transform: DAffine2, render_params: &RenderParams) { + render.parent_tag( + "g", + |attributes| { + attributes.push("clip-path", format!("url(#{clip_id})")); + }, + |render| { + let matrix = format_transform_matrix(item_transform); + if matrix.is_empty() { + fill_graphic_list.render_svg(render, render_params); + return; + } + render.parent_tag( + "g", + |attributes| { + attributes.push(ATTR_TRANSFORM, matrix); + }, + |render| { + fill_graphic_list.render_svg(render, render_params); + }, + ); + }, + ); +} + +/// Emits the fill element for aligned-stroke paths, dispatching between `` for Color/Gradient and `` for Vector/Raster/Graphic. +#[allow(clippy::too_many_arguments)] +fn emit_aligned_fill_pass( + render: &mut SvgRender, + d: String, + element_transform: DAffine2, + item_transform: DAffine2, + fill_graphic_list: Option<&List>, + clip_id: Option<&str>, + applied_stroke_transform: DAffine2, + bounds_matrix: DAffine2, + transformed_bounds_matrix: DAffine2, + render_params: &RenderParams, +) { + let fill_graphic = fill_graphic_list.and_then(|l| l.element(0)); + match fill_graphic { + Some(Graphic::Color(_) | Graphic::Gradient(_)) | None => { + emit_svg_fill_path( + render, + d, + element_transform, + fill_graphic, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); + } + Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_)) => { + if let (Some(clip_id), Some(fill_graphic_list)) = (clip_id, fill_graphic_list) { + emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); + } + } + } +} + // TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes. // This will require that the transform for child layers into that layer space be calculated, or it could be returned from the RenderOutput instead of click targets. #[derive(Debug, Default, Clone, PartialEq, DynAny)] @@ -922,7 +1037,7 @@ impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { let Some(vector) = self.element(index) else { continue }; - let multiplied_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); @@ -930,9 +1045,9 @@ impl Render for List { // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); - let applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); + let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); - let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse()); + let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse()); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let layer_bounds = vector.bounding_box().unwrap_or_default(); let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); @@ -953,32 +1068,46 @@ impl Render for List { MaskType::Mask }; + let fill_graphic_list = self + .attribute::>(ATTR_FILL_GRAPHIC, index) + .filter(|list| !list.is_empty()) + .cloned() + .or_else(|| fill_to_graphic_list(vector.style.fill())); + let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); + + let need_clipping = matches!(fill_graphic, Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_))); + 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_use_paint_order = !(vector.style.fill().is_none() || !vector.style.fill().is_opaque() || mask_type == MaskType::Clip); + let can_use_paint_order = !(fill_graphic.is_none() || !fill_covers_opaquely(fill_graphic) || mask_type == MaskType::Clip || need_clipping); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); + let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + let use_face_fill = vector.use_face_fill(); + + // Register the clipPath in and remember its id for the below + let clip_id = if need_clipping && !use_face_fill { + let id = format!("clip-{}", generate_uuid()); + write!(&mut render.svg_defs, r##""##).unwrap(); + Some(id) + } else { + None + }; if needs_separate_alignment_fill && !wants_stroke_below { - render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); + emit_aligned_fill_pass( + render, + path.clone(), + element_transform, + item_transform, + fill_graphic_list.as_ref(), + clip_id.as_deref(), + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } let push_id = needs_separate_alignment_fill.then_some({ @@ -990,38 +1119,51 @@ impl Render for List { // The mask must draw at full alpha so the SVG ``/`` fully zeroes the path interior. // The wrapping SVG group (above) handles the user-set opacity. - let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, multiplied_transform)); + let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform)); (id, mask_type, vector_item) }); - let use_face_fill = vector.use_face_fill(); if use_face_fill { for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - let face_d = face_path.to_svg(); - render.leaf_tag("path", |attributes| { - attributes.push("d", face_d.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); + + match fill_graphic { + Some(Graphic::Color(_) | Graphic::Gradient(_)) | None => { + emit_svg_fill_path( + render, + face_d, + element_transform, + fill_graphic, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_only = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_only); - }); + + Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_)) => { + if let Some(fill_graphic_list) = fill_graphic_list.as_ref() { + let face_clip_id = format!("clip-{}", generate_uuid()); + write!(&mut render.svg_defs, r##""##).unwrap(); + emit_svg_fill_clip(render, &face_clip_id, fill_graphic_list, item_transform, render_params); + } + } + } } } + // Clipping-based fill should be drawn before the stroke path (default paint order) + if !needs_separate_alignment_fill + && !use_face_fill + && !wants_stroke_below + && !override_paint_order + && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_ref()) + { + emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); + } + render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); let matrix = format_transform_matrix(element_transform); @@ -1058,20 +1200,34 @@ impl Render for List { let mut render_params = render_params.clone(); render_params.aligned_strokes = can_draw_aligned_stroke; - render_params.override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + render_params.override_paint_order = override_paint_order; - let mut style = vector.style.clone(); - if needs_separate_alignment_fill || use_face_fill { - style.clear_fill(); - } + let stroke_attribute = vector + .style + .stroke() + .map(|stroke| stroke.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params)) + .unwrap_or_default(); - let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params); + let fill_attribute = if needs_separate_alignment_fill || use_face_fill { + r#" fill="none""#.to_string() + } else { + compute_svg_fill_attribute( + fill_graphic, + defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + ) + }; if let Some((id, mask_type, _)) = push_id { let selector = format!("url(#{id})"); attributes.push(mask_type.to_attribute(), selector); } - attributes.push_val(fill_and_stroke); + attributes.push_val(fill_attribute); + attributes.push_val(stroke_attribute); if vector.is_branching() && !use_face_fill { attributes.push("fill-rule", "evenodd"); @@ -1087,26 +1243,29 @@ impl Render for List { } }); + // Clipping-based fill should be drawn after the stroke path + if !needs_separate_alignment_fill + && !use_face_fill + && (wants_stroke_below || override_paint_order) + && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_ref()) + { + emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); + } + // When splitting passes and stroke is below, draw the fill after the stroke. if needs_separate_alignment_fill && wants_stroke_below { - render.leaf_tag("path", |attributes| { - attributes.push("d", path); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); + emit_aligned_fill_pass( + render, + path, + element_transform, + item_transform, + fill_graphic_list.as_ref(), + clip_id.as_deref(), + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } } } @@ -2119,3 +2278,84 @@ impl SvgRenderAttrs<'_> { self.0.svg.push(value.into()); } } + +#[cfg(test)] +mod svg_fill_helper_tests { + use vector_types::GradientStop; + + use super::*; + + fn color_graphic(alpha: f64) -> Graphic { + let color = Color::from_rgbaf32(1.0, 0.0, 0.0, alpha as f32).unwrap(); + Graphic::Color(List::new_from_element(color)) + } + + fn gradient_graphic(gradient: GradientStops) -> Graphic { + let mut gradient_list = List::new_from_element(gradient); + gradient_list.set_attribute(ATTR_SPREAD_METHOD, 0, GradientSpreadMethod::Pad); + Graphic::Gradient(gradient_list) + } + + #[test] + fn opaquely_none_is_false() { + assert!(!fill_covers_opaquely(None)); + } + + #[test] + fn opaquely_opaque_color_is_true() { + let g = color_graphic(1.0); + assert!(fill_covers_opaquely(Some(&g))); + } + + #[test] + fn opaquely_transparent_color_is_false() { + let g = color_graphic(0.5); + assert!(!fill_covers_opaquely(Some(&g))); + } + + #[test] + fn opaquely_vector_is_false() { + let g = Graphic::Vector(List::default()); + assert!(!fill_covers_opaquely(Some(&g))); + } + + #[test] + fn opaquely_gradient_all_opaque_is_true() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(fill_covers_opaquely(Some(&g))); + } + + #[test] + fn opaquely_transparent_gradient_is_false() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 0.5).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(!fill_covers_opaquely(Some(&g))); + } +} From 28592352f2843f1c4704c57726448c27b07ce415 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 15 May 2026 14:15:08 +0900 Subject: [PATCH 06/18] Use Cow to avoid cloning graphic list for fill --- node-graph/libraries/rendering/src/renderer.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index f9433f0488..a24fa01505 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -27,6 +27,7 @@ use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, S use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, Cap, Join, Shape}; use num_traits::Zero; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::ops::Deref; @@ -1071,8 +1072,8 @@ impl Render for List { let fill_graphic_list = self .attribute::>(ATTR_FILL_GRAPHIC, index) .filter(|list| !list.is_empty()) - .cloned() - .or_else(|| fill_to_graphic_list(vector.style.fill())); + .map(Cow::Borrowed) + .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 need_clipping = matches!(fill_graphic, Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_))); @@ -1101,7 +1102,7 @@ impl Render for List { path.clone(), element_transform, item_transform, - fill_graphic_list.as_ref(), + fill_graphic_list.as_deref(), clip_id.as_deref(), applied_stroke_transform, bounds_matrix, @@ -1259,7 +1260,7 @@ impl Render for List { path, element_transform, item_transform, - fill_graphic_list.as_ref(), + fill_graphic_list.as_deref(), clip_id.as_deref(), applied_stroke_transform, bounds_matrix, @@ -1347,8 +1348,8 @@ impl Render for List { let Some(fill_graphic) = self .attribute::>(ATTR_FILL_GRAPHIC, index) .filter(|t| !t.is_empty()) - .cloned() - .or_else(|| fill_to_graphic_list(element.style.fill())) + .map(Cow::Borrowed) + .or_else(|| fill_to_graphic_list(element.style.fill()).map(Cow::Owned)) else { return; }; From 42991b41cafb0162bb324568212841627ea30c7d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 15 May 2026 15:30:42 +0900 Subject: [PATCH 07/18] Cleanup for Cow usage --- .../libraries/rendering/src/renderer.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a24fa01505..1cc54b63ef 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1145,7 +1145,7 @@ impl Render for List { } Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_)) => { - if let Some(fill_graphic_list) = fill_graphic_list.as_ref() { + if let Some(fill_graphic_list) = fill_graphic_list.as_deref() { let face_clip_id = format!("clip-{}", generate_uuid()); write!(&mut render.svg_defs, r##""##).unwrap(); emit_svg_fill_clip(render, &face_clip_id, fill_graphic_list, item_transform, render_params); @@ -1160,7 +1160,7 @@ impl Render for List { && !use_face_fill && !wants_stroke_below && !override_paint_order - && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_ref()) + && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_deref()) { emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); } @@ -1248,7 +1248,7 @@ impl Render for List { if !needs_separate_alignment_fill && !use_face_fill && (wants_stroke_below || override_paint_order) - && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_ref()) + && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_deref()) { emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); } @@ -1342,17 +1342,16 @@ impl Render for List { let use_layer = can_draw_aligned_stroke; let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); + // Try to use ATTR_FILL_GRAPHIC attribute, which is set by `fill_graphic` debug node, then fall back to Fill enum. + // TODO: Drop the Fill fall back once the Fill node becomes ready to store corresponding Graphic list directly. + let fill_graphic_list: Option>> = self + .attribute::>(ATTR_FILL_GRAPHIC, index) + .filter(|t| !t.is_empty()) + .map(Cow::Borrowed) + .or_else(|| fill_to_graphic_list(element.style.fill()).map(Cow::Owned)); + let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { - // Try to use ATTR_FILL_GRAPHIC attribute, which is set by `fill_graphic` debug node, then fall back to Fill enum. - // TODO: Drop the Fill fall back once the Fill node becomes ready to store corresponding Graphic list directly. - let Some(fill_graphic) = self - .attribute::>(ATTR_FILL_GRAPHIC, index) - .filter(|t| !t.is_empty()) - .map(Cow::Borrowed) - .or_else(|| fill_to_graphic_list(element.style.fill()).map(Cow::Owned)) - else { - return; - }; + let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; for paint_idx in 0..fill_graphic.len() { let Some(paint) = fill_graphic.element(paint_idx) else { continue }; From a03afedab3c2b3a4b08adf9b52945478ed1fdd10 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 15 May 2026 15:32:39 +0900 Subject: [PATCH 08/18] format code --- node-graph/libraries/rendering/src/render_ext.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 7ac21df4f2..5455607958 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,6 +1,6 @@ use crate::renderer::{RenderParams, format_transform_matrix}; -use core_types::list::List; use core_types::color::SRGBA8; +use core_types::list::List; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; From ac3f31794db6705f10e84b57b38e1aebede5f170 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 18 May 2026 16:19:41 +0900 Subject: [PATCH 09/18] Use `` instead of `` for clip This simplifies the future implementation of clipping-based rendering for strokes, as the stroke does not support the use of a clip path but rather paint sources from a paint server. --- .../libraries/rendering/src/renderer.rs | 220 ++++++++---------- 1 file changed, 91 insertions(+), 129 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 1cc54b63ef..e5d54ee6a5 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -220,6 +220,8 @@ pub struct RenderParams { pub alignment_parent_transform: Option, pub aligned_strokes: bool, pub override_paint_order: bool, + // Are we rendering for a pattern content + pub inside_pattern: bool, pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, @@ -235,8 +237,12 @@ impl RenderParams { Self { alignment_parent_transform, ..*self } } + pub fn for_pattern(&self) -> Self { + Self { inside_pattern: true, ..*self } + } + pub fn to_canvas(&self) -> bool { - !self.for_export && !self.thumbnail && !self.for_mask + !self.for_export && !self.thumbnail && !self.for_mask && !self.inside_pattern } } @@ -340,23 +346,73 @@ fn fill_covers_opaquely(fill_graphic: Option<&Graphic>) -> bool { } } +/// Emits a SVG `` paint server element that renders any graphic element into def and returns the id. +/// Currently this function uses `` as a clip-based paint server, which means the content is rendered once without tiling. +fn render_svg_fill_pattern(svg_defs: &mut String, fill_graphic_list: &List, path_bbox: [DVec2; 2], item_transform: DAffine2, render_params: &RenderParams) -> Option { + let [min, max] = path_bbox; + let size = max - min; + if size.x <= 0. || size.y <= 0. { + return None; + } + + // Render the pattern content recursively + let mut content = SvgRender::new(); + fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); + + // Unwrap the inner def element + write!(svg_defs, "{}", content.svg_defs).unwrap(); + + let pattern_transform = item_transform * DAffine2::from_translation(min); + let transform_str = format_transform_matrix(pattern_transform); + let transform_attr = if transform_str.is_empty() { + String::new() + } else { + format!(r#" patternTransform="{transform_str}""#) + }; + + let pattern_id = format!("pattern-{}", generate_uuid()); + write!( + svg_defs, + r##""##, + size.x, size.y, + ) + .unwrap(); + + let content_shift = format_transform_matrix(DAffine2::from_translation(-min)); + write!(svg_defs, r##"{}"##, content.svg.to_svg_string()).unwrap(); + + Some(pattern_id) +} + /// Returns the fill attribute for SVG tags corresponding to the given fill_graphic. +#[allow(clippy::too_many_arguments)] fn compute_svg_fill_attribute( - fill_graphic: Option<&Graphic>, + fill_graphic_list: Option<&List>, defs: &mut String, element_transform: DAffine2, applied_stroke_transform: DAffine2, bounds_matrix: DAffine2, transformed_bounds_matrix: DAffine2, + item_transform: DAffine2, render_params: &RenderParams, ) -> String { + let fill_graphic = fill_graphic_list.and_then(|l| l.element(0)); + match fill_graphic { Some(Graphic::Color(color_list)) => color_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params), Some(Graphic::Gradient(gradient_list)) => { let gradient_id = gradient_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params); - format!(r##" fill="url('#{gradient_id}')""##) + format!(r##" fill="url(#{gradient_id})""##) } - _ => r#" fill="none""#.to_string(), + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => { + let list = fill_graphic_list.unwrap(); + let min = bounds_matrix.transform_point2(DVec2::ZERO); + let max = bounds_matrix.transform_point2(DVec2::ONE); + render_svg_fill_pattern(defs, list, [min, max], item_transform, render_params) + .map(|id| format!(r##" fill="url(#{id})""##)) + .unwrap_or_else(|| r#" fill="none""#.to_string()) + } + None => r#" fill="none""#.to_string(), } } @@ -366,10 +422,11 @@ fn emit_svg_fill_path( render: &mut SvgRender, d: String, element_transform: DAffine2, - fill_graphic: Option<&Graphic>, + fill_graphic_list: Option<&List>, applied_stroke_transform: DAffine2, bounds_matrix: DAffine2, transformed_bounds_matrix: DAffine2, + item_transform: DAffine2, render_params: &RenderParams, ) { render.leaf_tag("path", |attributes| { @@ -379,73 +436,20 @@ fn emit_svg_fill_path( attributes.push(ATTR_TRANSFORM, matrix); } let defs = &mut attributes.0.svg_defs; - let fill_attribute = compute_svg_fill_attribute(fill_graphic, defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params); + let fill_attribute = compute_svg_fill_attribute( + fill_graphic_list, + defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + item_transform, + render_params, + ); attributes.push_val(fill_attribute); }); } -/// Emits an SVG `` group that renders the fill graphic clipped to the path referenced by `clip_id`. -fn emit_svg_fill_clip(render: &mut SvgRender, clip_id: &str, fill_graphic_list: &List, item_transform: DAffine2, render_params: &RenderParams) { - render.parent_tag( - "g", - |attributes| { - attributes.push("clip-path", format!("url(#{clip_id})")); - }, - |render| { - let matrix = format_transform_matrix(item_transform); - if matrix.is_empty() { - fill_graphic_list.render_svg(render, render_params); - return; - } - render.parent_tag( - "g", - |attributes| { - attributes.push(ATTR_TRANSFORM, matrix); - }, - |render| { - fill_graphic_list.render_svg(render, render_params); - }, - ); - }, - ); -} - -/// Emits the fill element for aligned-stroke paths, dispatching between `` for Color/Gradient and `` for Vector/Raster/Graphic. -#[allow(clippy::too_many_arguments)] -fn emit_aligned_fill_pass( - render: &mut SvgRender, - d: String, - element_transform: DAffine2, - item_transform: DAffine2, - fill_graphic_list: Option<&List>, - clip_id: Option<&str>, - applied_stroke_transform: DAffine2, - bounds_matrix: DAffine2, - transformed_bounds_matrix: DAffine2, - render_params: &RenderParams, -) { - let fill_graphic = fill_graphic_list.and_then(|l| l.element(0)); - match fill_graphic { - Some(Graphic::Color(_) | Graphic::Gradient(_)) | None => { - emit_svg_fill_path( - render, - d, - element_transform, - fill_graphic, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - } - Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_)) => { - if let (Some(clip_id), Some(fill_graphic_list)) = (clip_id, fill_graphic_list) { - emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); - } - } - } -} - // TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes. // This will require that the transform for child layers into that layer space be calculated, or it could be returned from the RenderOutput instead of click targets. #[derive(Debug, Default, Clone, PartialEq, DynAny)] @@ -1076,37 +1080,25 @@ 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 need_clipping = matches!(fill_graphic, Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_))); - 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_use_paint_order = !(fill_graphic.is_none() || !fill_covers_opaquely(fill_graphic) || mask_type == MaskType::Clip || need_clipping); + let can_use_paint_order = !(fill_graphic.is_none() || !fill_covers_opaquely(fill_graphic) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let use_face_fill = vector.use_face_fill(); - // Register the clipPath in and remember its id for the below - let clip_id = if need_clipping && !use_face_fill { - let id = format!("clip-{}", generate_uuid()); - write!(&mut render.svg_defs, r##""##).unwrap(); - Some(id) - } else { - None - }; - if needs_separate_alignment_fill && !wants_stroke_below { - emit_aligned_fill_pass( + emit_svg_fill_path( render, path.clone(), element_transform, - item_transform, fill_graphic_list.as_deref(), - clip_id.as_deref(), applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, + item_transform, render_params, ); } @@ -1130,41 +1122,20 @@ impl Render for List { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); let face_d = face_path.to_svg(); - match fill_graphic { - Some(Graphic::Color(_) | Graphic::Gradient(_)) | None => { - emit_svg_fill_path( - render, - face_d, - element_transform, - fill_graphic, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - } - - Some(Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_)) => { - if let Some(fill_graphic_list) = fill_graphic_list.as_deref() { - let face_clip_id = format!("clip-{}", generate_uuid()); - write!(&mut render.svg_defs, r##""##).unwrap(); - emit_svg_fill_clip(render, &face_clip_id, fill_graphic_list, item_transform, render_params); - } - } - } + emit_svg_fill_path( + render, + face_d, + element_transform, + fill_graphic_list.as_deref(), + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + item_transform, + render_params, + ); } } - // Clipping-based fill should be drawn before the stroke path (default paint order) - if !needs_separate_alignment_fill - && !use_face_fill - && !wants_stroke_below - && !override_paint_order - && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_deref()) - { - emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); - } - render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); let matrix = format_transform_matrix(element_transform); @@ -1213,12 +1184,13 @@ impl Render for List { r#" fill="none""#.to_string() } else { compute_svg_fill_attribute( - fill_graphic, + fill_graphic_list.as_deref(), defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, + item_transform, &render_params, ) }; @@ -1244,27 +1216,17 @@ impl Render for List { } }); - // Clipping-based fill should be drawn after the stroke path - if !needs_separate_alignment_fill - && !use_face_fill - && (wants_stroke_below || override_paint_order) - && let (Some(clip_id), Some(fill_graphic_list)) = (clip_id.as_ref(), fill_graphic_list.as_deref()) - { - emit_svg_fill_clip(render, clip_id, fill_graphic_list, item_transform, render_params); - } - // When splitting passes and stroke is below, draw the fill after the stroke. if needs_separate_alignment_fill && wants_stroke_below { - emit_aligned_fill_pass( + emit_svg_fill_path( render, - path, + path.clone(), element_transform, - item_transform, fill_graphic_list.as_deref(), - clip_id.as_deref(), applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, + item_transform, render_params, ); } From 189557e1503ae2a709cd67123cabaee0d6c21e0e Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 19 May 2026 10:56:06 +0900 Subject: [PATCH 10/18] Move svg pattern rendering function to RenderExt --- .../libraries/rendering/src/render_ext.rs | 97 +++++++++++- .../libraries/rendering/src/renderer.rs | 145 ++++++------------ 2 files changed, 141 insertions(+), 101 deletions(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 5455607958..694ccb8d75 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,9 +1,11 @@ use crate::renderer::{RenderParams, format_transform_matrix}; +use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; +use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientType; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; @@ -12,7 +14,17 @@ use vector_types::gradient::GradientSpreadMethod; pub trait RenderExt { type Output; - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output; + #[allow(clippy::too_many_arguments)] + fn render( + &self, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + render_params: &RenderParams, + ) -> Self::Output; } impl RenderExt for List { @@ -21,6 +33,7 @@ impl RenderExt for List { fn render( &self, _svg_defs: &mut String, + _item_transform: DAffine2, _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, @@ -42,7 +55,16 @@ impl RenderExt for List { type Output = u64; /// Adds the gradient def through mutating the first argument, returning the gradient ID. - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, _render_params: &RenderParams) -> Self::Output { + fn render( + &self, + svg_defs: &mut String, + _item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + _render_params: &RenderParams, + ) -> Self::Output { let mut stop = String::new(); let Some(stops) = self.element(0) else { return 0 }; @@ -118,6 +140,7 @@ impl RenderExt for Stroke { fn render( &self, _svg_defs: &mut String, + _item_transform: DAffine2, _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, @@ -174,3 +197,73 @@ impl RenderExt for Stroke { attributes } } + +impl RenderExt for List { + type Output = String; + + fn render( + &self, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + render_params: &RenderParams, + ) -> Self::Output { + let fill_graphic = self.element(0); + + 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::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})""##) + } + 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()) + } + None => r#" fill="none""#.to_string(), + } + } +} + +/// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the fill content, and returns the pattern ID. +/// Currently, this function is only used for clipping-based filling, not for tiling. +fn render_svg_fill_pattern(svg_defs: &mut String, fill_graphic_list: &List, item_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { + let min = bounds.transform_point2(DVec2::ZERO); + let max = bounds.transform_point2(DVec2::ONE); + let size = max - min; + if size.x <= 0. || size.y <= 0. { + return None; + } + + // Render the pattern content recursively + let mut content = SvgRender::new(); + fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); + + // Unwrap the inner def element + write!(svg_defs, "{}", content.svg_defs).unwrap(); + + let pattern_transform = item_transform * DAffine2::from_translation(min); + let transform_str = format_transform_matrix(pattern_transform); + let transform_attr = if transform_str.is_empty() { + String::new() + } else { + format!(r#" patternTransform="{transform_str}""#) + }; + + let pattern_id = format!("pattern-{}", generate_uuid()); + write!( + svg_defs, + r##""##, + size.x, size.y, + ) + .unwrap(); + + let content_shift = format_transform_matrix(DAffine2::from_translation(-min)); + write!(svg_defs, r##"{}"##, content.svg.to_svg_string()).unwrap(); + + Some(pattern_id) +} diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index e5d54ee6a5..0a3493e9f1 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -346,87 +346,17 @@ fn fill_covers_opaquely(fill_graphic: Option<&Graphic>) -> bool { } } -/// Emits a SVG `` paint server element that renders any graphic element into def and returns the id. -/// Currently this function uses `` as a clip-based paint server, which means the content is rendered once without tiling. -fn render_svg_fill_pattern(svg_defs: &mut String, fill_graphic_list: &List, path_bbox: [DVec2; 2], item_transform: DAffine2, render_params: &RenderParams) -> Option { - let [min, max] = path_bbox; - let size = max - min; - if size.x <= 0. || size.y <= 0. { - return None; - } - - // Render the pattern content recursively - let mut content = SvgRender::new(); - fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); - - // Unwrap the inner def element - write!(svg_defs, "{}", content.svg_defs).unwrap(); - - let pattern_transform = item_transform * DAffine2::from_translation(min); - let transform_str = format_transform_matrix(pattern_transform); - let transform_attr = if transform_str.is_empty() { - String::new() - } else { - format!(r#" patternTransform="{transform_str}""#) - }; - - let pattern_id = format!("pattern-{}", generate_uuid()); - write!( - svg_defs, - r##""##, - size.x, size.y, - ) - .unwrap(); - - let content_shift = format_transform_matrix(DAffine2::from_translation(-min)); - write!(svg_defs, r##"{}"##, content.svg.to_svg_string()).unwrap(); - - Some(pattern_id) -} - -/// Returns the fill attribute for SVG tags corresponding to the given fill_graphic. -#[allow(clippy::too_many_arguments)] -fn compute_svg_fill_attribute( - fill_graphic_list: Option<&List>, - defs: &mut String, - element_transform: DAffine2, - applied_stroke_transform: DAffine2, - bounds_matrix: DAffine2, - transformed_bounds_matrix: DAffine2, - item_transform: DAffine2, - render_params: &RenderParams, -) -> String { - let fill_graphic = fill_graphic_list.and_then(|l| l.element(0)); - - match fill_graphic { - Some(Graphic::Color(color_list)) => color_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params), - Some(Graphic::Gradient(gradient_list)) => { - let gradient_id = gradient_list.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, render_params); - format!(r##" fill="url(#{gradient_id})""##) - } - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => { - let list = fill_graphic_list.unwrap(); - let min = bounds_matrix.transform_point2(DVec2::ZERO); - let max = bounds_matrix.transform_point2(DVec2::ONE); - render_svg_fill_pattern(defs, list, [min, max], item_transform, render_params) - .map(|id| format!(r##" fill="url(#{id})""##)) - .unwrap_or_else(|| r#" fill="none""#.to_string()) - } - None => r#" fill="none""#.to_string(), - } -} - /// Emits an SVG `` element with the resolved fill attribute corresponding to the given fill_graphic. #[allow(clippy::too_many_arguments)] fn emit_svg_fill_path( render: &mut SvgRender, d: String, - element_transform: DAffine2, fill_graphic_list: Option<&List>, + item_transform: DAffine2, + element_transform: DAffine2, applied_stroke_transform: DAffine2, bounds_matrix: DAffine2, transformed_bounds_matrix: DAffine2, - item_transform: DAffine2, render_params: &RenderParams, ) { render.leaf_tag("path", |attributes| { @@ -436,16 +366,19 @@ fn emit_svg_fill_path( attributes.push(ATTR_TRANSFORM, matrix); } let defs = &mut attributes.0.svg_defs; - let fill_attribute = compute_svg_fill_attribute( - fill_graphic_list, - defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - item_transform, - render_params, - ); + let fill_attribute = fill_graphic_list + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()); attributes.push_val(fill_attribute); }); } @@ -1093,12 +1026,12 @@ impl Render for List { emit_svg_fill_path( render, path.clone(), - element_transform, fill_graphic_list.as_deref(), + item_transform, + element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, - item_transform, render_params, ); } @@ -1125,12 +1058,12 @@ impl Render for List { emit_svg_fill_path( render, face_d, - element_transform, fill_graphic_list.as_deref(), + item_transform, + element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, - item_transform, render_params, ); } @@ -1177,22 +1110,36 @@ impl Render for List { let stroke_attribute = vector .style .stroke() - .map(|stroke| stroke.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params)) + .map(|stroke| { + stroke.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + ) + }) .unwrap_or_default(); let fill_attribute = if needs_separate_alignment_fill || use_face_fill { r#" fill="none""#.to_string() } else { - compute_svg_fill_attribute( - fill_graphic_list.as_deref(), - defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - item_transform, - &render_params, - ) + fill_graphic_list + .as_deref() + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()) }; if let Some((id, mask_type, _)) = push_id { @@ -1221,12 +1168,12 @@ impl Render for List { emit_svg_fill_path( render, path.clone(), - element_transform, fill_graphic_list.as_deref(), + item_transform, + element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, - item_transform, render_params, ); } From 1802829c87853d32d2eecbb125cd10fbe603225b Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 19 May 2026 10:57:12 +0900 Subject: [PATCH 11/18] Fix comment --- node-graph/libraries/rendering/src/renderer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 0a3493e9f1..a1f57dca28 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -220,7 +220,7 @@ pub struct RenderParams { pub alignment_parent_transform: Option, pub aligned_strokes: bool, pub override_paint_order: bool, - // Are we rendering for a pattern content + /// Are we rendering for a pattern content pub inside_pattern: bool, pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. From 3091a6f2df1a41eb0123aa6e8aa01ea278159d76 Mon Sep 17 00:00:00 2001 From: YohYamasaki <74522538+YohYamasaki@users.noreply.github.com> Date: Tue, 19 May 2026 04:25:36 +0200 Subject: [PATCH 12/18] Fix empty fill list rendering as default black Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- node-graph/libraries/rendering/src/render_ext.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 694ccb8d75..b641e0bd2f 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -40,7 +40,7 @@ impl RenderExt for List { _transformed_bounds: DAffine2, _render_params: &RenderParams, ) -> Self::Output { - let Some(color) = self.element(0) else { return String::new() }; + 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()); if color.a() < 1. { From 3fd61169ae6e2cca34029b08976b41e5b2525f96 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 19 May 2026 16:33:18 +0900 Subject: [PATCH 13/18] Move opaque check function to Graphic impl --- .../libraries/graphic-types/src/graphic.rs | 84 +++++++++++++++++ .../libraries/rendering/src/renderer.rs | 92 +------------------ 2 files changed, 85 insertions(+), 91 deletions(-) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index eebf7e73a6..4d92e302cf 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -356,6 +356,14 @@ impl Graphic { _ => false, } } + + pub fn is_opaque(&self) -> bool { + match self { + Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), + Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.a() >= 1. - f32::EPSILON)), + _ => false, + } + } } impl BoundingBox for Graphic { @@ -481,3 +489,79 @@ impl OmitIndex for List { self.omit_index(self.len() - index) } } + +#[cfg(test)] +mod graphic_is_opaque_tests { + use vector_types::{GradientSpreadMethod, GradientStop}; + + use super::*; + + fn color_graphic(alpha: f64) -> Graphic { + let color = Color::from_rgbaf32(1.0, 0.0, 0.0, alpha as f32).unwrap(); + Graphic::Color(List::new_from_element(color)) + } + + fn gradient_graphic(gradient: GradientStops) -> Graphic { + let mut gradient_list = List::new_from_element(gradient); + gradient_list.set_attribute(ATTR_SPREAD_METHOD, 0, GradientSpreadMethod::Pad); + Graphic::Gradient(gradient_list) + } + + #[test] + fn opaque_color_is_opaque() { + let g = color_graphic(1.0); + assert!(g.is_opaque()); + } + + #[test] + fn transparent_color_is_not_opaque() { + let g = color_graphic(0.5); + assert!(!g.is_opaque()); + } + + #[test] + fn vector_is_not_opaque() { + let g = Graphic::Vector(List::default()); + assert!(!g.is_opaque()); + } + + #[test] + fn gradient_with_all_opaque_stops_is_opaque() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(g.is_opaque()); + } + + #[test] + fn gradient_with_transparent_stop_is_not_opaque() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 0.5).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(!g.is_opaque()); + } +} diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a1f57dca28..197161e5a3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -337,15 +337,6 @@ fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_p scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path); } -/// Returns true if the resolved fill graphic fully and opaquely covers the path interior. -fn fill_covers_opaquely(fill_graphic: Option<&Graphic>) -> bool { - match fill_graphic { - Some(Graphic::Color(list)) => list.element(0).is_some_and(|c| c.a() >= 1.0), - Some(Graphic::Gradient(list)) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.a() >= 1.0)), - _ => false, - } -} - /// Emits an SVG `` element with the resolved fill attribute corresponding to the given fill_graphic. #[allow(clippy::too_many_arguments)] fn emit_svg_fill_path( @@ -1015,7 +1006,7 @@ impl Render for List { 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_use_paint_order = !(fill_graphic.is_none() || !fill_covers_opaquely(fill_graphic) || mask_type == MaskType::Clip); + 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; let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); @@ -2187,84 +2178,3 @@ impl SvgRenderAttrs<'_> { self.0.svg.push(value.into()); } } - -#[cfg(test)] -mod svg_fill_helper_tests { - use vector_types::GradientStop; - - use super::*; - - fn color_graphic(alpha: f64) -> Graphic { - let color = Color::from_rgbaf32(1.0, 0.0, 0.0, alpha as f32).unwrap(); - Graphic::Color(List::new_from_element(color)) - } - - fn gradient_graphic(gradient: GradientStops) -> Graphic { - let mut gradient_list = List::new_from_element(gradient); - gradient_list.set_attribute(ATTR_SPREAD_METHOD, 0, GradientSpreadMethod::Pad); - Graphic::Gradient(gradient_list) - } - - #[test] - fn opaquely_none_is_false() { - assert!(!fill_covers_opaquely(None)); - } - - #[test] - fn opaquely_opaque_color_is_true() { - let g = color_graphic(1.0); - assert!(fill_covers_opaquely(Some(&g))); - } - - #[test] - fn opaquely_transparent_color_is_false() { - let g = color_graphic(0.5); - assert!(!fill_covers_opaquely(Some(&g))); - } - - #[test] - fn opaquely_vector_is_false() { - let g = Graphic::Vector(List::default()); - assert!(!fill_covers_opaquely(Some(&g))); - } - - #[test] - fn opaquely_gradient_all_opaque_is_true() { - let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); - let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); - let gradient = GradientStops::new(vec![ - GradientStop { - position: 0., - midpoint: 0.5, - color: color_1, - }, - GradientStop { - position: 1., - midpoint: 0.5, - color: color_2, - }, - ]); - let g = gradient_graphic(gradient); - assert!(fill_covers_opaquely(Some(&g))); - } - - #[test] - fn opaquely_transparent_gradient_is_false() { - let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 0.5).unwrap(); - let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); - let gradient = GradientStops::new(vec![ - GradientStop { - position: 0., - midpoint: 0.5, - color: color_1, - }, - GradientStop { - position: 1., - midpoint: 0.5, - color: color_2, - }, - ]); - let g = gradient_graphic(gradient); - assert!(!fill_covers_opaquely(Some(&g))); - } -} From 746d78364cef2542ae1d8545f98a5b47470da59d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 19 May 2026 13:16:31 +0900 Subject: [PATCH 14/18] Add color converter and debug node to use graphic --- node-graph/libraries/core-types/src/list.rs | 3 +++ .../libraries/graphic-types/src/graphic.rs | 8 +++++- node-graph/nodes/math/src/lib.rs | 25 ++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 7a7df7d6ca..81f57435dd 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -80,6 +80,9 @@ pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; /// List data for fill. pub const ATTR_FILL_GRAPHIC: &str = "fill_graphic"; +/// List data for stroke. +pub const ATTR_STROKE_PAINT_GRAPHIC: &str = "stroke_paint_graphic"; + // ======================== // TRAIT: AnyAttributeValue // ======================== diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 4d92e302cf..1bfc8195bf 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -171,7 +171,7 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) } /// Converts a `Fill` enum into the `List` representation used as paint storage. -/// TODO: Remove once all paint sources flow through `List` directly without going through the `Fill` enum. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. pub fn fill_to_graphic_list(fill: &Fill) -> Option> { match fill { Fill::None => None, @@ -188,6 +188,12 @@ pub fn fill_to_graphic_list(fill: &Fill) -> Option> { } } +/// Converts a `Color` into the `List` representation used as paint storage. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn color_to_graphic_list(color: Option) -> Option> { + color.as_ref().map(|color| List::new_from_element((*color).into())) +} + /// Maps from a concrete element type to its corresponding `Graphic` enum variant, /// enabling type-directed casting of typed `List`s from a `Graphic` value. pub trait TryFromGraphic: Clone + Sized { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 43ffa38619..5843552006 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,5 +1,5 @@ use core_types::Context; -use core_types::list::{ATTR_FILL_GRAPHIC, List}; +use core_types::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, List}; use core_types::registry::types::{Fraction, Percentage, PixelSize}; use core_types::transform::Footprint; use core_types::{Color, Ctx, num_traits}; @@ -1008,6 +1008,29 @@ fn fill_graphic( vectors } +/// Sets the `stroke_paint_graphic` attribute on each item of the input vector list. +/// Used for testing of gradient and clipping-based stroke rendering until the proper Stroke node refactor lands. +#[node_macro::node(category("Debug"))] +fn stroke_paint_graphic( + _: impl Ctx, + mut vectors: List, + #[implementations( + List, + List, + List>, + List>, + List, + List, + )] + stroke_paint_graphic: P, +) -> List { + let paint_list = stroke_paint_graphic.into_graphic_list(); + for row_idx in 0..vectors.len() { + vectors.set_attribute(ATTR_STROKE_PAINT_GRAPHIC, row_idx, paint_list.clone()); + } + vectors +} + #[cfg(test)] mod test { use super::*; From 1adeb4cedd4f96c1157d66f0b42e74205915ca0a Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 19 May 2026 16:19:19 +0900 Subject: [PATCH 15/18] WIP: Use List to render Color & Gradient --- .../document/document_message_handler.rs | 1 + .../libraries/graphic-types/src/graphic.rs | 24 ++++++- .../libraries/rendering/src/render_ext.rs | 52 ++++++++++---- .../libraries/rendering/src/renderer.rs | 69 +++++++++++++++---- .../vector-types/src/vector/style.rs | 2 +- 5 files changed, 116 insertions(+), 32 deletions(-) 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. } } From 4285243a5dd9c53be82982a9a02ed02861ff4a8c Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 20 May 2026 16:09:39 +0900 Subject: [PATCH 16/18] Use `Arc>` for vector_data metadata This exposes List's attributes to message handlers, enabling them to access the necessary attribute data such as ATTR_STROKE_PAINT_GRAPHIC as `Fill` and `Stroke` will not have paint information in the future. --- .../portfolio/document/document_message.rs | 6 ++- .../document/document_message_handler.rs | 37 ++++++++++++++----- .../utility_types/document_metadata.rs | 5 ++- .../utility_types/network_interface.rs | 8 ++-- .../libraries/rendering/src/renderer.rs | 13 ++++--- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 7cffbf2cf3..8c68f89aa7 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -12,6 +12,7 @@ use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; use graphene_std::Color; +use graphene_std::list::List; use graphene_std::raster::BlendMode; use graphene_std::raster::Image; use graphene_std::transform::Footprint; @@ -226,8 +227,11 @@ pub enum DocumentMessage { UpdateClipTargets { clip_targets: HashSet, }, + // `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it. + // `UpdateVectorData` is produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point. + #[serde(skip)] UpdateVectorData { - vector_data: HashMap>, + vector_data: HashMap>>, }, Undo, UngroupSelectedLayers, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index f987e28146..3096345f87 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -32,6 +32,9 @@ use glam::{DAffine2, DVec2}; use graph_craft::descriptor; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; +use graphene_std::Graphic; +use graphene_std::graphic::{color_to_graphic_list, fill_to_graphic_list}; +use graphene_std::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, List}; use graphene_std::math::quad::Quad; use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; @@ -40,8 +43,9 @@ use graphene_std::subpath::Subpath; use graphene_std::vector::PointId; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::dvec2_to_point; -use graphene_std::vector::style::{Fill, RenderMode}; +use graphene_std::vector::style::{RenderMode, Stroke}; use kurbo::{Affine, BezPath, Line, PathSeg}; +use std::borrow::Cow; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; @@ -2384,17 +2388,32 @@ impl DocumentMessageHandler { let mut resulting_layers: Vec = Vec::new(); for layer in selected_layers { - let style = self.network_interface.document_metadata().layer_vector_data.get(&layer).map(|arc| arc.style.clone()); - let Some(style) = style else { + let vector_list = self.network_interface.document_metadata().layer_vector_data.get(&layer).cloned(); + let Some(vector_list) = vector_list else { resulting_layers.push(layer.to_node()); continue; }; - - 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()); + let style = vector_list.element(0).map(|vector| &vector.style); + + let fill_graphic_list = vector_list + .attribute::>(ATTR_FILL_GRAPHIC, 0) + .filter(|list| !list.is_empty()) + .map(Cow::Borrowed) + .or_else(|| style.and_then(|style| fill_to_graphic_list(style.fill())).map(Cow::Owned)); + let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); + + let stroke_paint_graphic_list = vector_list + .attribute::>(ATTR_STROKE_PAINT_GRAPHIC, 0) + .filter(|list| !list.is_empty()) + .map(Cow::Borrowed) + .or_else(|| color_to_graphic_list(style.and_then(|style| 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 has_fill = fill_graphic.is_some(); + + let stroke_renderable = style.is_some_and(|s| s.stroke.as_ref().is_some_and(Stroke::has_renderable_stroke)); + let stroke_paint_visible = stroke_paint_graphic.is_some_and(|g| !g.is_fully_transparent()); + let has_stroke = stroke_renderable && stroke_paint_visible; // No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip. if !has_stroke { diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 496fa909c8..534af08e01 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -6,6 +6,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Flow use crate::messages::tool::common_functionality::graph_modification_utils; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; +use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath; use graphene_std::transform::Footprint; @@ -38,7 +39,7 @@ pub struct DocumentMetadata { pub vector_modify: HashMap, /// Vector data keyed by layer ID, used as fallback when no Path node exists. /// This provides accurate SegmentIds for layers without explicit Path nodes. - pub layer_vector_data: HashMap>, + pub layer_vector_data: HashMap>>, /// Transform from document space to viewport space. pub document_to_viewport: DAffine2, } @@ -225,7 +226,7 @@ impl DocumentMetadata { /// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based /// bounds for non-vector layers (groups, raster, text, color, gradient). pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> { - if let Some(vector) = self.layer_vector_data.get(&layer) + if let Some(vector) = self.layer_vector_data.get(&layer).and_then(|vector_list| vector_list.element(0)) && let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer)) { return Some(bounds); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0253f707f6..d4bb99ba05 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -23,6 +23,7 @@ use graph_craft::Type; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graphene_std::ContextDependencies; +use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath::Subpath; use graphene_std::transform::Footprint; @@ -3230,8 +3231,9 @@ impl NodeNetworkInterface { } return Some(modified); } - - self.document_metadata.layer_vector_data.get(&layer).map(|arc| arc.as_ref().clone()) + // Only item 0 is returned since editing tools can only target a single item currently. + let vector_list = self.document_metadata.layer_vector_data.get(&layer).cloned(); + vector_list.and_then(|list| list.element(0).cloned()) } /// The vector geometry an upstream Path node would surface for editing. @@ -3393,7 +3395,7 @@ impl NodeNetworkInterface { } /// Update the layer vector data (for layers without Path nodes) - pub fn update_vector_data(&mut self, new_layer_vector_data: HashMap>) { + pub fn update_vector_data(&mut self, new_layer_vector_data: HashMap>>) { self.document_metadata.layer_vector_data = new_layer_vector_data; } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 13c03f63f9..b0336b06c4 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -391,7 +391,9 @@ pub struct RenderMetadata { /// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage. pub text_frames: HashMap, pub clip_targets: HashSet, - pub vector_data: HashMap>, + // `RenderMetadata` only enters serialization via `TaggedValue::RenderOutput`, which also skips serde. + #[cfg_attr(feature = "serde", serde(skip))] + pub vector_data: HashMap>>, pub backgrounds: Vec, } @@ -1525,6 +1527,11 @@ impl Render for List { let mut accumulated_click_targets: HashMap>> = HashMap::new(); let mut accumulated_outlines: HashMap>> = HashMap::new(); + // Source geometry (not the click-target override) so editing tools work on letterforms. + if let Some(element_id) = caller_element_id { + metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(self.clone())); + } + for index in 0..self.len() { let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); @@ -1554,10 +1561,6 @@ impl Render for List { extend_targets_from_vector(&mut outlines_unwrapped, source, item_relative_transform); accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); - // Source geometry (not the click-target override) so editing tools work on letterforms. - // Only item 0 is recorded since editing tools can only target a single item currently. - metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(source.clone())); - // Surface `editor:text_frame` for the Text tool's drag cage if let Some(&frame) = self.attribute::(ATTR_EDITOR_TEXT_FRAME, index) { metadata.text_frames.entry(element_id).or_insert(frame); From 44c03a36de46857ab407d7133e7656e76bea2724 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 21 May 2026 10:19:08 +0900 Subject: [PATCH 17/18] Recurse opacity checks on nested `Graphic` Also extracts `fill_graphic_list_at` / `stroke_paint_graphic_list_at` to share the row-attribute lookup across the existing call sites. --- .../document/document_message_handler.rs | 22 ++---- .../libraries/graphic-types/src/graphic.rs | 74 ++++++++++++++++--- .../libraries/rendering/src/renderer.rs | 24 ++---- 3 files changed, 72 insertions(+), 48 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 3096345f87..3edad2c942 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -32,9 +32,7 @@ use glam::{DAffine2, DVec2}; use graph_craft::descriptor; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; -use graphene_std::Graphic; -use graphene_std::graphic::{color_to_graphic_list, fill_to_graphic_list}; -use graphene_std::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, List}; +use graphene_std::graphic::{fill_graphic_list_at, stroke_paint_graphic_list_at}; use graphene_std::math::quad::Quad; use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; @@ -45,7 +43,6 @@ use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::style::{RenderMode, Stroke}; use kurbo::{Affine, BezPath, Line, PathSeg}; -use std::borrow::Cow; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; @@ -2395,19 +2392,10 @@ impl DocumentMessageHandler { }; let style = vector_list.element(0).map(|vector| &vector.style); - let fill_graphic_list = vector_list - .attribute::>(ATTR_FILL_GRAPHIC, 0) - .filter(|list| !list.is_empty()) - .map(Cow::Borrowed) - .or_else(|| style.and_then(|style| fill_to_graphic_list(style.fill())).map(Cow::Owned)); - let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); - - let stroke_paint_graphic_list = vector_list - .attribute::>(ATTR_STROKE_PAINT_GRAPHIC, 0) - .filter(|list| !list.is_empty()) - .map(Cow::Borrowed) - .or_else(|| color_to_graphic_list(style.and_then(|style| 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 fill_graphic_list = fill_graphic_list_at(&vector_list, 0); + let fill_graphic = fill_graphic_list.as_deref().and_then(|l| l.element(0)); + let stroke_paint_graphic_list = stroke_paint_graphic_list_at(&vector_list, 0); + let stroke_paint_graphic = stroke_paint_graphic_list.as_deref().and_then(|l| l.element(0)); let has_fill = fill_graphic.is_some(); diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 09716a957b..95a17c6f1f 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; -use core_types::list::{ATTR_STROKE_PAINT_GRAPHIC, Item, List}; +use core_types::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, Item, List}; use core_types::ops::ListConvert; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; @@ -196,6 +196,29 @@ pub fn color_to_graphic_list(color: Option) -> Option> { color.as_ref().map(|color| List::new_from_element((*color).into())) } +/// Look up the fill paint graphics for a vector row, falling back to the legacy +/// `style.fill` when the row attribute is absent or empty. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. +pub fn fill_graphic_list_at(list: &List, index: usize) -> Option>> { + list.attribute::>(ATTR_FILL_GRAPHIC, index).filter(|l| !l.is_empty()).map(Cow::Borrowed).or_else(|| { + let vector = list.element(index)?; + fill_to_graphic_list(vector.style.fill()).map(Cow::Owned) + }) +} + +/// Look up the stroke paint graphics for a vector row, falling back to the legacy +/// `style.stroke.color` when the row attribute is absent or empty. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn stroke_paint_graphic_list_at(list: &List, index: usize) -> Option>> { + list.attribute::>(ATTR_STROKE_PAINT_GRAPHIC, index) + .filter(|l| !l.is_empty()) + .map(Cow::Borrowed) + .or_else(|| { + let vector = list.element(index)?; + color_to_graphic_list(vector.style.stroke().and_then(|s| s.color())).map(Cow::Owned) + }) +} + /// Maps from a concrete element type to its corresponding `Graphic` enum variant, /// enabling type-directed casting of typed `List`s from a `Graphic` value. pub trait TryFromGraphic: Clone + Sized { @@ -359,12 +382,9 @@ 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.); - 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)); + + let stroke_paint_graphic_list = stroke_paint_graphic_list_at(vector, index); + let stroke_paint_graphic = stroke_paint_graphic_list.as_deref().and_then(|l| l.element(0)); opacity > 1. - f64::EPSILON && element.style.fill().is_opaque() @@ -376,18 +396,48 @@ impl Graphic { pub fn is_opaque(&self) -> bool { match self { + Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque), + Graphic::Vector(list) => { + !list.is_empty() + && list.iter_element_values().enumerate().all(|(i, vector)| { + let style = &vector.style; + + let fill_graphic_list = fill_graphic_list_at(list, i); + let fill_graphic = fill_graphic_list.as_deref().and_then(|l| l.element(0)); + let stroke_paint_graphic_list = stroke_paint_graphic_list_at(list, i); + let stroke_paint_graphic = stroke_paint_graphic_list.as_deref().and_then(|l| l.element(0)); + + let fill_opaque = fill_graphic.is_some_and(|g| g.is_opaque()); + let stroke_opaque_or_invisible = style.stroke().is_none_or(|s| !s.has_renderable_stroke()) || stroke_paint_graphic.is_some_and(|g| g.is_opaque()); + + fill_opaque && stroke_opaque_or_invisible + }) + } Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.a() >= 1. - f32::EPSILON)), - _ => false, + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => 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, + Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent), + Graphic::Vector(list) => list.iter_element_values().enumerate().all(|(i, vector)| { + let style = &vector.style; + + let fill_graphic_list = fill_graphic_list_at(list, i); + let fill_graphic = fill_graphic_list.as_deref().and_then(|l| l.element(0)); + let stroke_paint_graphic_list = stroke_paint_graphic_list_at(list, i); + let stroke_paint_graphic = stroke_paint_graphic_list.as_deref().and_then(|l| l.element(0)); + + let fill_invisible = fill_graphic.is_none_or(|g| g.is_fully_transparent()); + let stroke_invisible = style.stroke().is_none_or(|s| !s.has_renderable_stroke()) || stroke_paint_graphic.is_none_or(|g| g.is_fully_transparent()); + + fill_invisible && stroke_invisible + }), + Graphic::Color(list) => list.iter_element_values().all(|c| c.a() == 0.), + Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b0336b06c4..4a9531503b 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -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, ATTR_STROKE_PAINT_GRAPHIC, Item, List}; +use core_types::list::{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::{color_to_graphic_list, fill_to_graphic_list}; +use graphic_types::graphic::{fill_graphic_list_at, stroke_paint_graphic_list_at}; 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; @@ -27,7 +27,6 @@ use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, S use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, Cap, Join, Shape}; use num_traits::Zero; -use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::ops::Deref; @@ -1000,18 +999,10 @@ impl Render for List { MaskType::Mask }; - let fill_graphic_list = self - .attribute::>(ATTR_FILL_GRAPHIC, index) - .filter(|list| !list.is_empty()) - .map(Cow::Borrowed) - .or_else(|| fill_to_graphic_list(vector.style.fill()).map(Cow::Owned)); + let fill_graphic_list = fill_graphic_list_at(self, index); 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_list = stroke_paint_graphic_list_at(self, index); 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()); @@ -1284,12 +1275,7 @@ impl Render for List { let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); // Try to use ATTR_FILL_GRAPHIC attribute, which is set by `fill_graphic` debug node, then fall back to Fill enum. - // TODO: Drop the Fill fall back once the Fill node becomes ready to store corresponding Graphic list directly. - let fill_graphic_list: Option>> = self - .attribute::>(ATTR_FILL_GRAPHIC, index) - .filter(|t| !t.is_empty()) - .map(Cow::Borrowed) - .or_else(|| fill_to_graphic_list(element.style.fill()).map(Cow::Owned)); + let fill_graphic_list = fill_graphic_list_at(self, index); let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; From 18362b11209a121e43dde47a596f328c8eca1d52 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 21 May 2026 12:05:28 +0900 Subject: [PATCH 18/18] Fix fill and stroke visibility check degradation --- .../libraries/graphic-types/src/graphic.rs | 4 ++- .../libraries/rendering/src/renderer.rs | 25 +++++++++++-------- node-graph/nodes/vector/src/vector_nodes.rs | 18 ++++++++++--- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 95a17c6f1f..1b8d3025e4 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -385,9 +385,11 @@ impl Graphic { let stroke_paint_graphic_list = stroke_paint_graphic_list_at(vector, index); let stroke_paint_graphic = stroke_paint_graphic_list.as_deref().and_then(|l| l.element(0)); + let fill_graphic_list = fill_graphic_list_at(vector, index); + let fill_graphic = fill_graphic_list.as_deref().and_then(|l| l.element(0)); opacity > 1. - f64::EPSILON - && element.style.fill().is_opaque() + && fill_graphic.is_none_or(|g| g.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, diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 4a9531503b..101a54aa30 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1123,7 +1123,8 @@ impl Render for List { .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()) { + let stroke_paint_visible = vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_paint_graphic.is_some_and(|g| !g.is_fully_transparent()); + let stroke_paint_attribute = if stroke_paint_visible { stroke_paint_graphic_list .as_deref() .map(|list| { @@ -1539,12 +1540,12 @@ impl Render for List { let item_relative_transform = item_zero_inverse * transform; let mut click_targets_unwrapped = Vec::new(); - extend_targets_from_vector(&mut click_targets_unwrapped, click_target_vector, item_relative_transform); + extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform); accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); // Outlines always use source geometry so the visual outline reflects actual letterforms let mut outlines_unwrapped = Vec::new(); - extend_targets_from_vector(&mut outlines_unwrapped, source, item_relative_transform); + extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform); accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); // Surface `editor:text_frame` for the Text tool's drag cage @@ -1581,7 +1582,7 @@ impl Render for List { // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) let vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); - extend_targets_from_vector(click_targets, vector, transform); + extend_targets_from_vector(click_targets, self, index, vector, transform); } } @@ -1591,7 +1592,7 @@ impl Render for List { let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - extend_targets_from_vector(outlines, source, transform); + extend_targets_from_vector(outlines, self, index, source, transform); } } @@ -1604,10 +1605,14 @@ impl Render for List { /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. -fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, transform: DAffine2) { - let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); - let filled = vector.style.fill() != &Fill::None; - let subpaths: Vec> = vector +fn extend_targets_from_vector(targets: &mut Vec, vector_list: &List, index: usize, geometry: &Vector, transform: DAffine2) { + let stroke_width = geometry.style.stroke().as_ref().map_or(0., Stroke::effective_width); + + let fill_graphic_list = fill_graphic_list_at(vector_list, index); + let fill_graphic = fill_graphic_list.as_deref().and_then(|l| l.element(0)); + let filled = fill_graphic.is_some(); + + let subpaths: Vec> = geometry .stroke_bezier_paths() .map(|mut subpath| { if filled { @@ -1622,7 +1627,7 @@ fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, t targets.push(click_target); } - for click_target in extend_free_point_targets(vector, transform) { + for click_target in extend_free_point_targets(geometry, transform) { targets.push(click_target); } } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index fe502add91..d1af8461f4 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU}; use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::list::{Item, List, ListDyn}; +use core_types::list::{ATTR_FILL_GRAPHIC, ATTR_STROKE_PAINT_GRAPHIC, Item, List, ListDyn}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::transform::{Footprint, Transform}; use core_types::uuid::NodeId; @@ -1224,13 +1224,23 @@ async fn solidify_stroke(_: impl Ctx, #[ } // If the original vector has a fill, preserve it as a separate item with the stroke cleared. - let has_fill = !vector.style.fill().is_none(); + let has_attr_fill = attributes.get::>(ATTR_FILL_GRAPHIC).is_some_and(|l| !l.is_empty()); + let has_fill = has_attr_fill || !vector.style.fill().is_none(); let fill_row = has_fill.then(|| { vector.style.clear_stroke(); - Item::from_parts(vector, attributes.clone()) + let mut fill_attributes = attributes.clone(); + // No stroke remains on the fill row + fill_attributes.remove::>(ATTR_STROKE_PAINT_GRAPHIC); + Item::from_parts(vector, fill_attributes) }); - let stroke_row = Item::from_parts(solidified_stroke, attributes); + let mut stroke_attributes = attributes; + // Drop the original fill and use the stroke paint to fill the outlined stroke + stroke_attributes.remove::>(ATTR_FILL_GRAPHIC); + if let Some(stroke_paint) = stroke_attributes.remove::>(ATTR_STROKE_PAINT_GRAPHIC) { + stroke_attributes.insert(ATTR_FILL_GRAPHIC, stroke_paint); + } + let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes); // Ordering based on the paint order. The first item in the `List` is rendered below the second. match paint_order {