From e192269481f406ab020d923e7dffb0e472918024 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 6 May 2026 17:19:57 +0900 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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))); - } -}