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 109e04c3f8..3edad2c942 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -32,6 +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::{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; @@ -40,7 +41,7 @@ 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::collections::HashSet; use std::path::PathBuf; @@ -2384,16 +2385,23 @@ 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 style = vector_list.element(0).map(|vector| &vector.style); - 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. - let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()); + 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(); + + 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/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 61f54597b4..81f57435dd 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -77,6 +77,12 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). 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 0efade8880..95a17c6f1f 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,10 +1,12 @@ +use std::borrow::Cow; + use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; -use core_types::list::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; -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 +14,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 +172,53 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) output } +/// Converts a `Fill` enum into the `List` representation used as paint storage. +/// 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, + Fill::Solid(color) => Some(List::new_from_element((*color).into())), + Fill::Gradient(gradient) => { + 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_list = List::new_from_item(gradient_row); + + Some(List::new_from_element(Graphic::Gradient(gradient_list))) + } + } +} + +/// 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())) +} + +/// 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 { @@ -332,11 +382,64 @@ 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 = 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() + && (element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || stroke_paint_graphic.is_none_or(|graphic| graphic.is_fully_transparent())) }), _ => false, } } + + 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)), + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, + } + } + + pub fn is_fully_transparent(&self) -> bool { + match self { + 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, + } + } } impl BoundingBox for Graphic { @@ -462,3 +565,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/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 883640d05d..df783d9006 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,24 +1,103 @@ 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 glam::DAffine2; -use graphic_types::vector_types::gradient::{Gradient, GradientType}; -use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +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; +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; - 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, + target: PaintTarget, + ) -> Self::Output; } -impl RenderExt for Gradient { +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, + target: PaintTarget, + ) -> Self::Output { + let Some(color) = self.element(0) else { return r#" fill="none""#.to_string() }; + + let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); + if color.a() < 1. { + let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.); + } + + result + } +} + +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, + _target: PaintTarget, + ) -> 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 +128,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, @@ -79,43 +158,22 @@ impl RenderExt for Gradient { } } -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 { - 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); - format!(r##" fill="url('#{gradient_id}')""##) - } - } - } -} - 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, + _item_transform: DAffine2, _element_transform: DAffine2, _stroke_transform: DAffine2, _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(); } @@ -133,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.; @@ -165,18 +220,74 @@ impl RenderExt for Stroke { } } -impl RenderExt for PathStyle { +impl RenderExt for List { 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}") + fn render( + &self, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + 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, 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, 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##" {paint_attr}="url(#{id})""##)) + .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)) + } + None => format!(r#" {paint_attr}="none""#), + } + } +} + +/// 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 6a6ca7c82b..4a9531503b 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; @@ -18,6 +18,7 @@ use core_types::{ use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; +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; @@ -218,6 +219,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, @@ -233,8 +236,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 } } @@ -329,6 +336,44 @@ fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_p scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path); } +/// 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, + fill_graphic_list: Option<&List>, + item_transform: DAffine2, + element_transform: DAffine2, + 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 = fill_graphic_list + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + PaintTarget::Fill, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()); + attributes.push_val(fill_attribute); + }); +} + // 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)] @@ -345,7 +390,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, } @@ -921,7 +968,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.); @@ -929,9 +976,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(); @@ -952,32 +999,35 @@ impl Render for List { MaskType::Mask }; + 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 = 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()); - 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_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; 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(); 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_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } let push_id = needs_separate_alignment_fill.then_some({ @@ -989,35 +1039,27 @@ 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); - } - 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); - }); + + emit_svg_fill_path( + render, + face_d, + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } } @@ -1057,20 +1099,77 @@ 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; - - let mut style = vector.style.clone(); - if needs_separate_alignment_fill || use_face_fill { - style.clear_fill(); - } + render_params.override_paint_order = override_paint_order; + + let stroke_shape_attribute = vector + .style + .stroke() + .map(|stroke| { + 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_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 { + fill_graphic_list + .as_deref() + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + PaintTarget::Fill, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()) + }; 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_shape_attribute); + attributes.push_val(stroke_paint_attribute); if vector.is_branching() && !use_face_fill { attributes.push("fill-rule", "evenodd"); @@ -1088,29 +1187,22 @@ impl Render for List { // 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_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } } } - 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() { @@ -1157,6 +1249,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; @@ -1181,75 +1274,95 @@ 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()), - }); - } + // Try to use ATTR_FILL_GRAPHIC attribute, which is set by `fill_graphic` debug node, then fall back to Fill enum. + let fill_graphic_list = fill_graphic_list_at(self, index); - let bounds = element.nonzero_bounding_box(); - let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + 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 }; - 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; - - let start = mod_points.transform_point2(gradient.start); - let end = mod_points.transform_point2(gradient.end); + for paint_idx in 0..fill_graphic.len() { + let Some(paint) = fill_graphic.element(paint_idx) else { continue }; + match paint { + Graphic::Color(list) => { + let Some(color) = list.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_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() { + 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); + } + 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(); + } }; - 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. 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())); @@ -1257,12 +1370,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); } }; @@ -1332,7 +1445,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.); @@ -1340,13 +1453,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.); @@ -1368,7 +1481,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.), } } @@ -1400,6 +1513,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); @@ -1429,10 +1547,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); 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); + } + } +} 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. } } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 3e864ee727..5843552006 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, 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}; 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,52 @@ 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 +} + +/// 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::*;