diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 908e883892..f0966637b7 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -23,7 +23,7 @@ use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{DashPattern, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{DashPattern, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType, MeshGradient, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Context, Graphic}; use std::any::Any; @@ -518,6 +518,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.identifier(), Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), + Self::MeshGradient(list) => list.identifier(), Self::Text(list) => list.identifier(), } } @@ -534,6 +535,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.layout_with_breadcrumb(data), Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), + Self::MeshGradient(list) => list.layout_with_breadcrumb(data), Self::Text(list) => list.layout_with_breadcrumb(data), } } @@ -740,6 +742,21 @@ impl TableItemLayout for Gradient { } } +impl TableItemLayout for MeshGradient { + fn type_name() -> &'static str { + "MeshGradient" + } + fn identifier(&self) -> String { + //FIXME: not implemented yet + format!("MeshGradient ({})", 1) + } + fn value_page(&self, _data: &mut LayoutData) -> Vec { + //FIXME: not implemented yet + let widgets = vec![TextLabel::new("FIXME: MeshGradient cannot be displayed here").widget_instance()]; + vec![LayoutGroup::row(widgets)] + } +} + impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number (f64)" diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index b61c33de82..ff9fa6c55c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2674,7 +2674,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte let mut row = vec![TextLabel::new("").widget_instance()]; add_blank_assist(&mut row); - let entries = [GradientType::Linear, GradientType::Radial] + let entries = [GradientType::Linear, GradientType::Radial, GradientType::Mesh] .iter() .map(|&grad_type| { RadioEntryData::new(format!("{:?}", grad_type)) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 940d7e6552..7ab9edb15c 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -252,8 +252,14 @@ impl LayoutHolder for GradientTool { } .into() }), + RadioEntryData::new("Mesh").label("Mesh").tooltip_label(" Gradient").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(GradientType::Mesh), + } + .into() + }), ]) - .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) + .selected_index(Some(self.options.gradient_type as u32)) .widget_instance(); // Display priority: the selected layer's stops, then any user-customized tool default, then the working colors diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index cd28de5d4b..33e7dc5129 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -15,7 +15,7 @@ use graphene_application_io::resource::ResourceId; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; -use graphic_types::vector_types::vector::style::Gradient; +use graphic_types::vector_types::vector::style::{Gradient, MeshGradient}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::RenderMetadata; @@ -96,6 +96,8 @@ macro_rules! tagged_value { #[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] Gradient(Gradient), + /// Stored compactly as a `MeshGradient`, materializing as an `Item` at runtime. + MeshGradient(MeshGradient), /// Stored compactly as a `Vec`, materializes as the single-value `Item` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "BrushStrokeTable")] @@ -141,6 +143,7 @@ macro_rules! tagged_value { Self::F64Array(values) => values.cache_hash(state), Self::Color(color) => color.cache_hash(state), Self::Gradient(stops) => stops.cache_hash(state), + Self::MeshGradient(mesh_gradient) => mesh_gradient.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS @@ -203,6 +206,7 @@ macro_rules! tagged_value { } Self::Color(color) => Box::new(Item::new_from_element(color)), Self::Gradient(stops) => Box::new(Item::new_from_element(stops)), + Self::MeshGradient(mesh_gradient) => Box::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -265,6 +269,7 @@ macro_rules! tagged_value { } Self::Color(color) => Arc::new(Item::new_from_element(color)), Self::Gradient(stops) => Arc::new(Item::new_from_element(stops)), + Self::MeshGradient(mesh_gradient) => Arc::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -293,6 +298,7 @@ macro_rules! tagged_value { Self::F64Array(_) => list!(f64), Self::Color(_) => item!(Color), Self::Gradient(_) => item!(Gradient), + Self::MeshGradient(_) => item!(MeshGradient), Self::BrushStrokes(_) => item!(BrushTrace), // ======================= // AUTO-GENERATED VARIANTS @@ -372,6 +378,7 @@ macro_rules! tagged_value { if name == std::any::type_name::<()>() { return Some(TaggedValue::None) } if name == std::any::type_name::() { return Some(TaggedValue::Color(Color::default())) } if name == std::any::type_name::() { return Some(TaggedValue::Gradient(Gradient::default())) } + if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradient::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::() { return Some(TaggedValue::BrushStrokes(Vec::new())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time @@ -425,6 +432,7 @@ macro_rules! tagged_value { Self::F64Array(values) => format!("F64Array({values:?})"), Self::Color(color) => format!("Color({color:?})"), Self::Gradient(stops) => format!("Gradient({stops:?})"), + Self::MeshGradient(mesh_gradient) => format!("MeshGradient({mesh_gradient:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index b8f95a434f..85970e91b0 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12815475172301479638), NodeId(13251389748338817266), NodeId(7166921994790432021), NodeId(15318519137317483318)] + vec![NodeId(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index c7f0bc5c80..be0088e97c 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -7,7 +7,7 @@ use graph_craft::proto::{NodeConstructor, TypeErasedBox}; use graphene_std::any::DynAnyNode; use graphene_std::brush::brush_stroke::BrushTrace; use graphene_std::extract_xy::XY; -use graphene_std::gradient::Gradient; +use graphene_std::gradient::{Gradient, MeshGradient}; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] use graphene_std::platform_application_io::canvas_utils::CanvasHandle; @@ -40,6 +40,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -48,6 +49,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -97,6 +99,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), @@ -114,6 +117,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -320,6 +324,7 @@ fn node_registry() -> HashMap HashMap, Color, Gradient, + MeshGradient, f64, bool, String, @@ -417,6 +423,7 @@ fn node_registry() -> HashMap HashMap), attribute_value_node!(List), attribute_value_node!(List), + attribute_value_node!(List), attribute_value_node!(List), attribute_value_node!(List>), #[cfg(feature = "gpu")] @@ -571,6 +579,7 @@ fn node_registry() -> HashMap), transform_list_node!(element: Color), transform_list_node!(element: Gradient), + transform_list_node!(element: MeshGradient), ]; node_types.extend(transform_list_rows); let mut map: HashMap> = HashMap::new(); diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index e5197f8e55..e3494c5c23 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -10,6 +10,7 @@ use raster_types::{CPU, GPU, Raster}; use std::borrow::Cow; use vector_types::Gradient; pub use vector_types::Vector; +use vector_types::gradient::MeshGradient; /// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)] @@ -23,6 +24,7 @@ pub enum Graphic { RasterGPU(List>), Color(List), Gradient(List), + MeshGradient(List), Text(List), } @@ -234,6 +236,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterCPU(list) => bake_list_transform(list, transform), Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform), + Graphic::MeshGradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform), Graphic::Color(_) => {} } @@ -339,6 +342,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::MeshGradient(self)) + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path = self.attribute::(ATTR_EDITOR_LAYER_PATH, 0).cloned(); @@ -427,6 +436,7 @@ impl Graphic { Graphic::RasterGPU(list) => all_clipped(list), Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), + Graphic::MeshGradient(list) => all_clipped(list), Graphic::Text(list) => all_clipped(list), } } @@ -467,7 +477,8 @@ impl Graphic { } 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.is_opaque())), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -491,7 +502,8 @@ impl Graphic { }), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -509,6 +521,7 @@ impl Graphic { Graphic::Vector(list) => list.is_empty(), Graphic::Color(list) => list.is_empty(), Graphic::Gradient(list) => list.is_empty(), + Graphic::MeshGradient(list) => list.is_empty(), Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), Graphic::Text(list) => list.is_empty(), @@ -526,6 +539,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(list) => list.bounding_box(transform, include_stroke), Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), + Graphic::MeshGradient(list) => list.bounding_box(transform, include_stroke), Graphic::Text(list) => list.bounding_box(transform, include_stroke), } } @@ -539,6 +553,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::MeshGradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), } } @@ -554,6 +569,7 @@ impl RenderComplexity for Graphic { Self::RasterGPU(list) => list.render_complexity(), Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), + Self::MeshGradient(list) => list.render_complexity(), Self::Text(list) => list.render_complexity(), } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 1113e5816c..6f72314aee 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -138,7 +138,7 @@ impl RenderExt for List { let gradient_id = generate_uuid(); match gradient_type { - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let _ = write!( svg_defs, r#"{}"#, @@ -242,7 +242,7 @@ impl RenderExt for List { format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::None) => format!(r#" {paint_attr}="none""#), - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::MeshGradient(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; @@ -263,7 +263,7 @@ impl RenderExt for List { } /// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID. -/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet. +/// Currently, this function is only used for clipping-based filling and stroking and mesh gradient, not considering tiling yet. fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { let min = bounds.transform_point2(DVec2::ZERO); let max = bounds.transform_point2(DVec2::ONE); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 4367dd53c0..97837e68b6 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,10 +2,8 @@ use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; use core_types::blending::BlendMode; -use core_types::bounds::BoundingBox; -use core_types::bounds::RenderBoundingBox; -use core_types::color::Color; -use core_types::color::SRGBA8; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::color::{Color, SRGBA8}; use core_types::consts::DEFAULT_FONT_SIZE; use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath}; use core_types::math::quad::Quad; @@ -18,7 +16,7 @@ use core_types::{ ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; -use glam::{DAffine2, DMat2, DVec2}; +use glam::{DAffine2, DMat2, DVec2, Vec4}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute}; @@ -28,7 +26,7 @@ use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Artboard, Graphic, Vector}; -use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; +use kurbo::{Affine, BezPath, Cap, CubicBez, Join, ParamCurve, PathSeg, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; @@ -39,7 +37,8 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::GradientSpreadMethod; +use vector_types::gradient::{GradientSpreadMethod, MeshGradient}; +use vector_types::vector::misc::point_to_dvec2; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -382,7 +381,7 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientType) -> DAffine2 { match gradient_type { GradientType::Radial => transform, - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let axis = transform.matrix2.x_axis; let band_normal = transform.matrix2.y_axis.perp(); let line = if band_normal.length_squared() > 0. { axis.project_onto(band_normal) } else { axis }; @@ -414,7 +413,7 @@ fn create_peniko_gradient_brush(gradient_list: &List, multiplied_trans let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { + GradientType::Linear | GradientType::Mesh => peniko::LinearGradientPosition { start: to_point(start), end: to_point(end), } @@ -553,6 +552,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), + Graphic::MeshGradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), } } @@ -566,6 +566,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::MeshGradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), } } @@ -621,6 +622,14 @@ impl Render for Graphic { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } + Graphic::MeshGradient(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } Graphic::Text(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -640,6 +649,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::MeshGradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), } } @@ -653,6 +663,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), + Graphic::MeshGradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), } } @@ -666,6 +677,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), + Graphic::MeshGradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), } } @@ -679,6 +691,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.contains_artboard(), Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), + Graphic::MeshGradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), } } @@ -692,6 +705,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), + Graphic::MeshGradient(_) => (), Graphic::Text(_) => (), } } @@ -1097,7 +1111,7 @@ impl Render for List { MaskType::Mask }; - let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL); + let fill_graphic_list: Option>> = graphic_list_at(self, index, ATTR_FILL); let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE); @@ -1381,6 +1395,9 @@ impl Render for List { paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); } + Graphic::MeshGradient(_) => { + // FIXME: implement vello rendering + } }; } }; @@ -1459,6 +1476,9 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } + Graphic::MeshGradient(_) => { + // FIXME: implement vello rendering + } Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); @@ -2122,7 +2142,7 @@ impl Render for List { // The unit gradient line is the +X unit vector in local space, before the item's transform is applied match gradient_type { - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let _ = write!( &mut attributes.0.svg_defs, r#"{stop_string}"# @@ -2189,7 +2209,7 @@ impl Render for List { // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. let kind = match gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { + GradientType::Linear | GradientType::Mesh => peniko::LinearGradientPosition { start: to_point(DVec2::ZERO), end: to_point(DVec2::X), } @@ -2237,6 +2257,122 @@ impl Render for List { } } +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..self.len() { + let Some(mesh_gradient) = self.element(index) else { continue }; + let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + // FIXME: use below attrs + 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.); + + // TODO: add adaptive subpatch size + let subdivisions_per_patch_per_axis = 4; + + let float_gamma_color_to_hex = |color: [f32; 4]| -> String { + let float_to_u8 = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8; + SRGBA8 { + red: float_to_u8(color[0]), + green: float_to_u8(color[1]), + blue: float_to_u8(color[2]), + alpha: float_to_u8(color[3]), + } + .to_rgba_hex() + }; + + let Some(subpatches) = mesh_gradient + .evaluator() + .and_then(|evaluator| evaluator.subdivide_patches(subdivisions_per_patch_per_axis, mesh_transform)) + else { + continue; + }; + + let clip_inflation = 0.01; + let paint_inflation = 0.02; + let clip_min = -clip_inflation; + let clip_size = 1. + 2. * clip_inflation; + let paint_min = -paint_inflation; + let paint_size = 1. + 2. * paint_inflation; + let shared_id = generate_uuid(); + write!( + &mut render.svg_defs, + r##" + + + "##, + ) + .unwrap(); + + let mut unique_id = generate_uuid(); + subpatches.iter().for_each(|subpatch| { + let [top_left, top_right, bottom_left, bottom_right] = subpatch.corners; + let subpatch_transform = format_transform_matrix(DAffine2::from_cols(top_right.position - top_left.position, bottom_left.position - top_left.position, top_left.position)); + + // linear gradient for the bottom line + write!( + &mut render.svg_defs, + r##""##, + float_gamma_color_to_hex(bottom_left.gamma_color), + float_gamma_color_to_hex(bottom_right.gamma_color), + ) + .unwrap(); + + // linear gradient for the top line + write!( + &mut render.svg_defs, + r##""##, + float_gamma_color_to_hex(top_left.gamma_color), + float_gamma_color_to_hex(top_right.gamma_color), + ) + .unwrap(); + + render.parent_tag( + "g", + |attributes| { + attributes.push(ATTR_TRANSFORM, subpatch_transform); + attributes.push("clip-path", format!("url(#mc{shared_id})")); + }, + |render| { + // Force both gradient layers to composite before the outer clip applies edge coverage. + render.parent_tag( + "g", + |attributes| { + attributes.push("style", "isolation:isolate"); + attributes.push("mask", format!("url(#mi{shared_id})")); + }, + |render| { + render.leaf_tag("rect", |attributes| { + attributes.push("x", paint_min.to_string()); + attributes.push("y", paint_min.to_string()); + attributes.push("width", paint_size.to_string()); + attributes.push("height", paint_size.to_string()); + attributes.push("fill", format!("url(#gb{unique_id})")); + }); + + render.leaf_tag("rect", |attributes| { + attributes.push("x", paint_min.to_string()); + attributes.push("y", paint_min.to_string()); + attributes.push("width", paint_size.to_string()); + attributes.push("height", paint_size.to_string()); + attributes.push("fill", format!("url(#gt{unique_id})")); + attributes.push("mask", format!("url(#mm{shared_id})")); + }); + }, + ); + }, + ); + + unique_id += 1; + }); + } + } + + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + // FIXME: + } +} + /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). struct GlyphOutlinePen<'a> { path: &'a mut BezPath, diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 0a9807d0aa..b0e8078c1e 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,7 +2,13 @@ use core_types::Color; use core_types::color::SRGBA8; use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DVec2, Vec4}; +use kurbo::{ParamCurve, PathSeg}; + +use crate::{ + Vector, + vector::{PointId, SegmentId, StrokeId, misc::point_to_dvec2}, +}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] @@ -12,6 +18,7 @@ pub enum GradientType { #[default] Linear, Radial, + Mesh, } // TODO: Someday we could switch this to a Box[T] to avoid over-allocation @@ -491,6 +498,501 @@ impl GradientSpreadMethod { } } +/// Resolved patch of a mesh gradient. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshPatch { + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Corner colors. [top-left, top-right, bottom-left, bottom-right] + pub colors: [Color; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], +} + +/// Definition of a mesh gradient patch. The entity is stored in the corresponding `MeshGradient` struct. +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +struct MeshPatchDefinition { + /// Corner indices in MeshGradient::corner_points/corner_colors. [top-left, top-right, bottom-left, bottom-right] + corner_indices: [usize; 4], + /// Segment ids of edges defining the patch. [top, bottom, left, right] + edges: [SegmentId; 4], +} + +/// Mesh gradient defined by multiple coons patches. +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MeshGradient { + mesh_geometry: Vector, + pub corner_rows: usize, + pub corner_columns: usize, + /// Flatten list of corner's point IDs, row-major order. + corner_points: Vec, + /// Flatten list of corner colors, row-major order. + corner_colors: Vec, + /// Flatten list of mesh patches, row-major order. + patch_defs: Vec, +} + +impl Default for MeshGradient { + fn default() -> Self { + // Build 2x2 patches + let corner_rows = 3; + let corner_columns = 3; + let positions: Vec = (0..corner_rows) + .flat_map(|row| { + let v = row as f64 / (corner_rows - 1) as f64; + (0..corner_columns).map(move |column| { + let u = column as f64 / (corner_columns - 1) as f64; + DVec2::new(u, v) + }) + }) + .collect(); + + MeshGradient::from_positions(positions.as_slice(), corner_rows, corner_columns).expect("2x2 patches should be valid mesh gradient") + } +} + +impl MeshGradient { + pub fn from_positions(positions: &[DVec2], corner_rows: usize, corner_columns: usize) -> Option { + if corner_rows < 2 || corner_columns < 2 { + return None; + } + + let corner_count = corner_rows.checked_mul(corner_columns)?; + if positions.len() != corner_count { + return None; + } + + let mut vector = Vector::default(); + let mut corner_points = Vec::with_capacity(corner_count); + + for &position in positions { + let point_id = vector.point_domain.next_id(); + vector.point_domain.push(point_id, position); + corner_points.push(point_id); + } + + let mut horizontal_edges = Vec::with_capacity(corner_rows * (corner_columns - 1)); + for row in 0..corner_rows { + for column in 0..(corner_columns - 1) { + let start_index = row * corner_columns + column; + let end_index = start_index + 1; + + let segment_id = vector.segment_domain.next_id(); + vector.push(segment_id, corner_points[start_index], corner_points[end_index], (None, None), StrokeId::ZERO); + horizontal_edges.push(segment_id); + } + } + + let mut vertical_edges = Vec::with_capacity((corner_rows - 1) * corner_columns); + for row in 0..(corner_rows - 1) { + for column in 0..corner_columns { + let start_index = row * corner_columns + column; + let end_index = start_index + corner_columns; + + let segment_id = vector.segment_domain.next_id(); + vector.push(segment_id, corner_points[start_index], corner_points[end_index], (None, None), StrokeId::ZERO); + vertical_edges.push(segment_id); + } + } + + // FIXME: alternative color is only for testing purpose + let corner_colors = (0..corner_rows) + .flat_map(|row| { + (0..corner_columns).map(move |column| { + let luminance = (row + column).is_multiple_of(2) as u8 as f32; + Color::from_luminance(luminance) + }) + }) + .collect(); + + let patch_rows = corner_rows - 1; + let patch_columns = corner_columns - 1; + let mut patch_defs = Vec::with_capacity((corner_rows - 1) * (corner_columns - 1)); + for row in 0..patch_rows { + for column in 0..patch_columns { + let top_edge_index = row * patch_columns + column; + let bottom_edge_index = (row + 1) * patch_columns + column; + let left_edge_index = row * corner_columns + column; + let right_edge_index = left_edge_index + 1; + let edges = [ + horizontal_edges[top_edge_index], + horizontal_edges[bottom_edge_index], + vertical_edges[left_edge_index], + vertical_edges[right_edge_index], + ]; + let top_left = row * corner_columns + column; + let top_right = top_left + 1; + let bottom_left = top_left + corner_columns; + let bottom_right = bottom_left + 1; + let corner_indices = [top_left, top_right, bottom_left, bottom_right]; + patch_defs.push(MeshPatchDefinition { corner_indices, edges }); + } + } + + Some(Self { + mesh_geometry: vector, + corner_rows, + corner_columns, + corner_points, + corner_colors, + patch_defs, + }) + } + + fn resolve_patch(&self, patch_def: &MeshPatchDefinition) -> Option { + // Normalizes the orientation of the patch edge. + let resolve_oriented_edge = |segment_id: SegmentId, expected_start: PointId, expected_end: PointId| -> Option { + let (actual_start, actual_end, _) = self.mesh_geometry.segment_points_from_id(segment_id)?; + let segment = self.mesh_geometry.path_segment_from_id(segment_id)?; + + if actual_start == expected_start && actual_end == expected_end { + Some(segment) + } else if actual_start == expected_end && actual_end == expected_start { + Some(segment.reverse()) + } else { + // Segment is not connected to the expected patch corners. + None + } + }; + + let [top_left_index, top_right_index, bottom_left_index, bottom_right_index] = patch_def.corner_indices; + + let top_left_id = *self.corner_points.get(top_left_index)?; + let top_right_id = *self.corner_points.get(top_right_index)?; + let bottom_left_id = *self.corner_points.get(bottom_left_index)?; + let bottom_right_id = *self.corner_points.get(bottom_right_index)?; + + let corners = [ + self.mesh_geometry.point_domain.position_from_id(top_left_id)?, + self.mesh_geometry.point_domain.position_from_id(top_right_id)?, + self.mesh_geometry.point_domain.position_from_id(bottom_left_id)?, + self.mesh_geometry.point_domain.position_from_id(bottom_right_id)?, + ]; + + let colors = [ + *self.corner_colors.get(top_left_index)?, + *self.corner_colors.get(top_right_index)?, + *self.corner_colors.get(bottom_left_index)?, + *self.corner_colors.get(bottom_right_index)?, + ]; + + let [top_edge_id, bottom_edge_id, left_edge_id, right_edge_id] = patch_def.edges; + + let edges = [ + resolve_oriented_edge(top_edge_id, top_left_id, top_right_id)?, + resolve_oriented_edge(bottom_edge_id, bottom_left_id, bottom_right_id)?, + resolve_oriented_edge(left_edge_id, top_left_id, bottom_left_id)?, + resolve_oriented_edge(right_edge_id, top_right_id, bottom_right_id)?, + ]; + + Some(MeshPatch { corners, colors, edges }) + } + + // Returns resolved patch by the provided row/column position, if any. + fn patch(&self, row: usize, column: usize) -> Option { + let patch_rows = self.corner_rows.checked_sub(1)?; + let patch_columns = self.corner_columns.checked_sub(1)?; + + if row >= patch_rows || column >= patch_columns { + return None; + } + + let patch_columns = self.corner_columns.saturating_sub(1); + let patch_index = row.checked_mul(patch_columns)?.checked_add(column)?; + let patch_def = self.patch_defs.get(patch_index)?; + + self.resolve_patch(patch_def) + } + + pub fn patches(&self) -> impl Iterator> + '_ { + let patch_rows = self.corner_rows.saturating_sub(1); + let patch_columns = self.corner_columns.saturating_sub(1); + (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) + } + + pub fn evaluator(&self) -> Option { + MeshGradientEvaluator::new(self) + } +} + +/// Single vertex of a subpatch. Only for rendering purpose. +#[derive(Clone, Copy)] + +pub struct MeshSubpatchVertex { + pub position: DVec2, + pub gamma_color: [f32; 4], +} + +pub struct MeshSubpatch { + pub corners: [MeshSubpatchVertex; 4], +} + +#[derive(Clone, Copy)] +struct MeshCornerDerivatives { + u: Vec4, + v: Vec4, +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +struct MeshPatchEvaluator { + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], + // sRGB gamma space color in 0.-1. [top-left, top-right, bottom-left, bottom-right] + gamma_colors: [Vec4; 4], + /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_slopes: [MeshCornerDerivatives; 4], + /// Linear length of between each corner. [top, bottom, left, right] + lengths: [f32; 4], +} + +impl MeshPatchEvaluator { + /// Evaluate interpolated color in a mesh gradient's patch using bicubic hermite interpolation. + fn eval_color(&self, u: f32, v: f32) -> Option<[f32; 4]> { + if !(0. ..=1.).contains(&u) || !(0. ..=1.).contains(&v) { + return None; + } + + let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { + let t_power_2 = t * t; + let t_power_3 = t_power_2 * t; + + let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; + let h2 = -2. * t_power_3 + 3. * t_power_2; + let h3 = t_power_3 - 2. * t_power_2 + t; + let h4 = t_power_3 - t_power_2; + + ma * h3 + a * h1 + b * h2 + mb * h4 + }; + + let [top_left_gamma, top_right_gamma, bottom_left_gamma, bottom_right_gamma] = self.gamma_colors; + let [top_length, bottom_length, left_length, right_length] = self.lengths; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; + + let interpolated_gamma_color: [f32; 4] = std::array::from_fn(|channel| { + let top_color_interpolated = hermite( + top_left_gamma[channel], + top_left_color_slope.u[channel] * top_length, + top_right_gamma[channel], + top_right_color_slope.u[channel] * top_length, + u, + ); + let bottom_color_interpolated = hermite( + bottom_left_gamma[channel], + bottom_left_color_slope.u[channel] * bottom_length, + bottom_right_gamma[channel], + bottom_right_color_slope.u[channel] * bottom_length, + u, + ); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); + hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) + }); + + Some(interpolated_gamma_color) + } + + fn eval_vertex(&self, u: f64, v: f64, mesh_transform: DAffine2) -> Option { + let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; + let [top_left, top_right, bottom_left, bottom_right] = self.corners; + + let top_u = point_to_dvec2(top_seg.eval(u)); + let bottom_u = point_to_dvec2(bottom_seg.eval(u)); + let left_v = point_to_dvec2(left_seg.eval(v)); + let right_v = point_to_dvec2(right_seg.eval(v)); + + let s_c = (1. - v) * top_u + v * bottom_u; + let s_d = (1. - u) * left_v + u * right_v; + let s_b = top_left * (1. - u) * (1. - v) + top_right * u * (1. - v) + bottom_left * (1. - u) * v + bottom_right * u * v; + + Some(MeshSubpatchVertex { + position: mesh_transform.transform_point2(s_c + s_d - s_b), + gamma_color: self.eval_color(u as f32, v as f32)?, + }) + } +} + +/// Struct for evaluating color for subpatch corners. +/// The main purpose is to prevent duplicated calculation of the slopes for hermite interpolation for each subpatch. +#[derive(Clone)] +pub struct MeshGradientEvaluator { + /// List of required data for color interpolation, row major order. + patches: Vec, +} + +impl MeshGradientEvaluator { + // TODO: probably it is better to use u/v for slope calculation + pub fn new(mesh_gradient: &MeshGradient) -> Option { + let &MeshGradient { corner_columns, corner_rows, .. } = mesh_gradient; + if corner_rows < 2 || corner_columns < 2 { + return None; + } + let patch_columns = corner_columns - 1; + let patch_rows = corner_rows - 1; + + if mesh_gradient.patch_defs.len() != patch_rows * patch_columns { + return None; + } + let corner_count = corner_rows.checked_mul(corner_columns)?; + if mesh_gradient.corner_points.len() != corner_count || mesh_gradient.corner_colors.len() != corner_count { + return None; + } + + let corner_positions: Vec = mesh_gradient + .corner_points + .iter() + .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) + .collect::>()?; + + // We need to calculate the color derivatives in sRGB since SVG uses sRGB for color interpolation. + // `color-interpolation="linearRGB"` is part of the SVG2 spec but not yet implemented in major browsers as of Jul. 2026. + // See also: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/color-interpolation + let gamma_colors: Vec = mesh_gradient.corner_colors.iter().map(|color| Vec4::from_array(color.to_gamma_srgb_channels())).collect(); + + // Calculate the slope of the `curr_index` corner by FDM. The slope is derived from the linear distance from the previous/next corners. + let calculate_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let prev_color = gamma_colors[prev_index]; + let curr_color = gamma_colors[curr_index]; + let next_color = gamma_colors[next_index]; + + let [prev_pos, curr_pos, next_pos] = [prev_index, curr_index, next_index].map(|index| corner_positions[index]); + let prev_distance = curr_pos.distance(prev_pos) as f32; + let next_distance = next_pos.distance(curr_pos) as f32; + + if prev_index == curr_index { + // FIXME: resolve zero-division problem + (next_color - curr_color) / next_distance + } else if next_index == curr_index { + (curr_color - prev_color) / prev_distance + } else { + let backward_diff = (curr_color - prev_color) / prev_distance; + let forward_diff = (next_color - curr_color) / next_distance; + let central_diff = (backward_diff + forward_diff) / 2.; + + // Prevent overshooting by applying a zero slope at local minimum/maximum + // TODO: consider clamping slope by a constant value + Vec4::from_array(std::array::from_fn( + |channel| { + if backward_diff[channel] * forward_diff[channel] <= 0. { 0. } else { central_diff[channel] } + }, + )) + } + }; + + let sample_index = |row: isize, column: isize| -> usize { + let clamped_column = column.clamp(0, corner_columns as isize - 1) as usize; + let clamped_row = row.clamp(0, corner_rows as isize - 1) as usize; + clamped_row * corner_columns + clamped_column + }; + + let mut corner_slopes = Vec::with_capacity(corner_rows * corner_columns); + for row in 0..corner_rows as isize { + for col in 0..corner_columns as isize { + let curr_index = sample_index(row, col); + let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + corner_slopes.push(MeshCornerDerivatives { u, v }); + } + } + + let mut patch_color_data: Vec = vec![]; + for patch_def in &mesh_gradient.patch_defs { + let patch = mesh_gradient.resolve_patch(patch_def)?; + let gamma_colors = patch_def + .corner_indices + .map(|index| gamma_colors.get(index).copied()) + .into_iter() + .collect::>>()? + .try_into() + .ok()?; + + let color_slopes = patch_def + .corner_indices + .map(|index| corner_slopes.get(index).copied()) + .into_iter() + .collect::>>()? + .try_into() + .ok()?; + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; + // [top, bottom, left, right] + let lengths = [ + top_left_pos.distance(top_right_pos) as f32, + bottom_left_pos.distance(bottom_right_pos) as f32, + top_left_pos.distance(bottom_left_pos) as f32, + top_right_pos.distance(bottom_right_pos) as f32, + ]; + patch_color_data.push(MeshPatchEvaluator { + corners: patch.corners, + edges: patch.edges, + gamma_colors, + color_slopes, + lengths, + }); + } + + Some(Self { patches: patch_color_data }) + } + + /// Subdivide all patches in a mesh into parallelogram subpatches so to renderable by two linear gradients with mask. + /// Returns subpatchs in row-major. + pub fn subdivide_patches(&self, subdivisions_per_patch_per_axis: usize, mesh_transform: DAffine2) -> Option> { + let count = subdivisions_per_patch_per_axis; + if count == 0 { + return None; + } + + let capacity = self.patches.len().checked_mul(count)?.checked_mul(count)?; + let mut subpatches = Vec::with_capacity(capacity); + + for patch in &self.patches { + let evaluate_row = |row: usize| -> Option> { + let v = row as f64 / count as f64; + + (0..=count) + .map(|column| { + let u = column as f64 / count as f64; + patch.eval_vertex(u, v, mesh_transform) + }) + .collect() + }; + + // Reusing the previous bottom row as a current top row to prevent duplicated evaluation on the same subpatch vertices. + let mut top_row = evaluate_row(0)?; + for row in 0..count { + let bottom_row = evaluate_row(row + 1)?; + for column in 0..count { + subpatches.push(MeshSubpatch { + corners: [top_row[column], top_row[column + 1], bottom_row[column], bottom_row[column + 1]], + }); + } + + top_row = bottom_row; + } + } + + Some(subpatches) + } +} + +impl RenderComplexity for MeshGradient { + fn render_complexity(&self) -> usize { + let patch_rows = self.corner_rows.saturating_sub(1); + let patch_columns = self.corner_columns.saturating_sub(1); + + // FIXME: probably better to calculate the approximate number of subpatchs + let subpatch_count_per_patch = 64; + patch_rows + .saturating_mul(patch_columns) + .saturating_mul(subpatch_count_per_patch) + .saturating_mul(subpatch_count_per_patch) + } +} + /// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both /// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint /// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks. @@ -575,3 +1077,17 @@ impl core_types::bounds::BoundingBox for Gradient { core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) } } + +impl core_types::bounds::BoundingBox for MeshGradient { + fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + // FIXME: infinite? finite? + core_types::bounds::RenderBoundingBox::Infinite + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + // FIXME: implement actual check of the bounding box + let start = transform.transform_point2(DVec2::ZERO); + let end = transform.transform_point2(DVec2::X); + core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + } +} diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index d66703a690..781d9fe228 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -8,7 +8,7 @@ pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientSpreadMethod, GradientStop, GradientType}; +pub use gradient::{Gradient, GradientSpreadMethod, GradientStop, GradientType, MeshGradient}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 63f9b87650..04686239b0 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -941,6 +941,15 @@ impl Vector { self.segment_points_from_id(id).map(|(_, _, bezier)| bezier) } + /// Tries to convert a segment with the specified id to a [`PathSeg`], returning None if the id is invalid. + pub fn path_segment_from_id(&self, id: SegmentId) -> Option { + let segment_index = self.segment_domain.id_to_index(id)?; + let start_index = *self.segment_domain.start_point().get(segment_index)?; + let end_index = *self.segment_domain.end_point().get(segment_index)?; + let handles = *self.segment_domain.handles().get(segment_index)?; + Some(self.path_segment_from_index(start_index, end_index, handles)) + } + /// Tries to convert a segment with the specified id to the start and end points and a [`Bezier`], returning None if the id is invalid. pub fn segment_points_from_id(&self, id: SegmentId) -> Option<(PointId, PointId, Bezier)> { Some(self.segment_points_from_index(self.segment_domain.id_to_index(id)?)) diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 54aa4084e1..525967b286 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -58,7 +58,7 @@ pub mod subpath { } pub mod gradient { - pub use vector_types::{Gradient, GradientStop}; + pub use vector_types::{Gradient, GradientStop, MeshGradient}; } pub mod transform { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 1474aa2be3..a62d7f9ba4 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -14,7 +14,7 @@ use math_parser::value::{Number, Value}; use num_traits::Pow; use rand::{Rng, SeedableRng}; use std::ops::{Add, Div, Mul, Rem, Sub}; -use vector_types::Gradient; +use vector_types::{Gradient, MeshGradient}; /// The struct that stores the context for the maths parser. /// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. @@ -1016,6 +1016,12 @@ fn spread_method(_: impl Ctx, gradient: Item, spread_method: Item) -> Item { + mesh_gradient +} + /// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). #[node_macro::node(category("Color"))] fn sample_gradient(_: impl Ctx, _primary: (), gradient: Item, position: Item) -> Item { diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index bcfed58a5a..de0dfac609 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -297,6 +297,8 @@ fn flatten_vector(graphic_list: &List) -> List { Item::from_parts(element, attributes) }) .collect::>(), + // FIXME: + Graphic::MeshGradient(mesh_gradient) => Vec::new(), Graphic::Text(text) => { // Shape the glyphs into vectors (each item's own transform is applied), then compose the parent's transform like the other arms let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index c2efc76a16..c1f4b39780 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -33,7 +33,7 @@ use vector_types::vector::misc::{ }; use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; -use vector_types::{GradientSpreadMethod, GradientType}; +use vector_types::{GradientSpreadMethod, GradientType, MeshGradient}; /// Implemented for `List` types that contain vector items reachable via mutable access. /// Used by the whole-collection Assign Colors node so it can apply to either `List` or `List`. @@ -178,13 +178,13 @@ where async fn fill( _: impl Ctx, /// The content with vector paths to apply the fill style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The fill to paint the path with. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] fill: F, _backup_color: Item, @@ -203,51 +203,78 @@ where let mut content = content; let mut fill = fill.into_graphic_list(); - // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire - for graphic in fill.iter_element_values_mut() { - let Graphic::Gradient(gradient) = graphic else { continue }; - - if gradient.iter_attribute_values::(ATTR_GRADIENT_TYPE).is_none() { - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_TYPE) { - *value = _gradient_type; - } - } - - if gradient.iter_attribute_values::(ATTR_SPREAD_METHOD).is_none() { - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_SPREAD_METHOD) { - *value = _spread_method; - } - } - - if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { - // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) - let transform = if _has_transform { - _transform - } else { - let mut bounds: Option<[DVec2; 2]> = None; - content.for_each_vector_mut(|vector, _| { - if let Some([min, max]) = vector.bounding_box() { - bounds = Some(match bounds { - Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], - None => [min, max], - }); - } + let mut vector_bounds = || { + let mut bounds: Option<[DVec2; 2]> = None; + content.for_each_vector_mut(|vector, _| { + if let Some([min, max]) = vector.bounding_box() { + bounds = Some(match bounds { + Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], + None => [min, max], }); - - // Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box` - let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); + } + }); + bounds + .map(|bounds| { + let [min, mut max] = bounds; if max.x - min.x < 1e-10 { max.x = min.x + 1.; } if max.y - min.y < 1e-10 { max.y = min.y + 1.; } - initial_gradient_transform_for_bounding_box([min, max]) - }; + [min, max] + }) + .unwrap_or([DVec2::ZERO, DVec2::ONE]) + }; + + // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire + for graphic in fill.iter_element_values_mut() { + match graphic { + Graphic::Gradient(gradient) => { + if gradient.iter_attribute_values::(ATTR_GRADIENT_TYPE).is_none() { + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_TYPE) { + *value = _gradient_type; + } + } + + if gradient.iter_attribute_values::(ATTR_SPREAD_METHOD).is_none() { + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_SPREAD_METHOD) { + *value = _spread_method; + } + } + + if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { + // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) + let transform = if _has_transform { + _transform + } else { + // Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box` + let [min, max] = vector_bounds(); + initial_gradient_transform_for_bounding_box([min, max]) + }; - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - *value = transform; + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } + } + Graphic::MeshGradient(mesh_gradient) => { + if mesh_gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { + // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) + let transform = if _has_transform { + _transform + } else { + let [min, max] = vector_bounds(); + let size = max - min; + DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) + }; + + for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } } + _ => continue, } } @@ -260,13 +287,13 @@ where async fn stroke( _: impl Ctx, /// The content with vector paths to apply the stroke style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The stroke paint. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] paint: P, /// The stroke thickness. @@ -324,7 +351,7 @@ where vector.stroke = Some(stroke); }); - let paint = paint.into_graphic_list(); + let paint: List = paint.into_graphic_list(); content.set_vector_paint(ATTR_STROKE, paint); content }