diff --git a/Cargo.lock b/Cargo.lock index e36ea02788d..4ffd2a0b943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2041,6 +2041,28 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "gradient-nodes" +version = "0.1.0" +dependencies = [ + "brush-types", + "bytemuck", + "core-types", + "dyn-any", + "glam", + "graphene-hash", + "graphic-types", + "half", + "log", + "node-macro", + "raster-types", + "serde", + "tokio", + "vector-types", + "wgpu", + "wgpu-executor", +] + [[package]] name = "graph-craft" version = "0.1.0" @@ -2191,6 +2213,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "gradient-nodes", "graph-craft", "graphene-application-io", "graphene-canvas-utils", @@ -4897,6 +4920,7 @@ dependencies = [ "graphene-hash", "graphene-resource", "graphic-types", + "image", "kurbo", "log", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index 194897cbdbc..676e27bec7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ graphic-types = { path = "node-graph/libraries/graphic-types" } brush-types = { path = "node-graph/libraries/brush-types" } rendering = { path = "node-graph/libraries/rendering" } brush-nodes = { path = "node-graph/nodes/brush" } +gradient-nodes = { path = "node-graph/nodes/gradient" } blending-nodes = { path = "node-graph/nodes/blending" } graphene-core = { path = "node-graph/nodes/gcore" } graphic-nodes = { path = "node-graph/nodes/graphic" } diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index c1b4309649d..393b0b6794b 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -183,6 +183,16 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(MouseRight); action_dispatch=GradientToolMessage::Abort), entry!(KeyDown(Escape); action_dispatch=GradientToolMessage::Abort), // + // MeshGradientToolMessage + entry!(DoubleClick(MouseButton::Left); action_dispatch=MeshGradientToolMessage::DoubleClick), + entry!(KeyDown(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerDown), + entry!(PointerMove; refresh_keys=[Shift], action_dispatch=MeshGradientToolMessage::PointerMove { constrain_axis: Shift }), + entry!(KeyUp(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerUp), + entry!(KeyDown(Delete); action_dispatch=MeshGradientToolMessage::DeleteEdge), + entry!(KeyDown(Backspace); action_dispatch=MeshGradientToolMessage::DeleteEdge), + entry!(KeyDown(MouseRight); action_dispatch=MeshGradientToolMessage::Abort), + entry!(KeyDown(Escape); action_dispatch=MeshGradientToolMessage::Abort), + // // ShapeToolMessage entry!(KeyDown(MouseLeft); action_dispatch=ShapeToolMessage::DragStart), entry!(KeyUp(MouseLeft); action_dispatch=ShapeToolMessage::DragStop), 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 f4775b033ad..55fb08b4111 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 @@ -27,7 +27,7 @@ use graphene_std::vector::misc::{ SpiralType, }; use graphene_std::vector::style::{ - DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin, + DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, MeshGradient, StrokeAlign, StrokeCap, StrokeJoin, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Appearance, Artboard, Color, Context, Cover, Coverage, Graphic}; @@ -210,6 +210,7 @@ fn generate_layout(introspected_data: &Arc>, List, List, + List, List, List, List, @@ -265,6 +266,7 @@ fn generate_layout(introspected_data: &Arc>, Item, Item, + Item, Item, Item, Item, @@ -607,6 +609,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(item) => item.identifier(), Self::Color(item) => item.identifier(), Self::Gradient(item) => item.identifier(), + Self::MeshGradient(item) => item.identifier(), Self::Text(item) => item.identifier(), Self::NoneList(list) => list.identifier(), Self::GraphicList(list) => list.identifier(), @@ -615,6 +618,7 @@ impl TableItemLayout for Graphic { Self::RasterGPUList(list) => list.identifier(), Self::ColorList(list) => list.identifier(), Self::GradientList(list) => list.identifier(), + Self::MeshGradientList(list) => list.identifier(), Self::TextList(list) => list.identifier(), Self::StrokeList(list) => list.identifier(), } @@ -632,6 +636,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(item) => item.layout_with_breadcrumb(data), Self::Color(item) => item.layout_with_breadcrumb(data), Self::Gradient(item) => item.layout_with_breadcrumb(data), + Self::MeshGradient(item) => item.layout_with_breadcrumb(data), Self::Text(item) => item.layout_with_breadcrumb(data), Self::NoneList(list) => list.layout_with_breadcrumb(data), Self::GraphicList(list) => list.layout_with_breadcrumb(data), @@ -640,6 +645,7 @@ impl TableItemLayout for Graphic { Self::RasterGPUList(list) => list.layout_with_breadcrumb(data), Self::ColorList(list) => list.layout_with_breadcrumb(data), Self::GradientList(list) => list.layout_with_breadcrumb(data), + Self::MeshGradientList(list) => list.layout_with_breadcrumb(data), Self::TextList(list) => list.layout_with_breadcrumb(data), Self::StrokeList(list) => list.layout_with_breadcrumb(data), } @@ -809,6 +815,33 @@ impl TableItemLayout for Gradient { } } +impl TableItemLayout for MeshGradient { + fn type_name() -> &'static str { + "MeshGradient" + } + fn identifier(&self) -> String { + format!("MeshGradient ({} corners)", self.size()) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + let mut rows = vec![column_headings(&["corner", "point ID", "position", "color"])]; + rows.extend(self.corners().map(|corner| { + vec![ + TextLabel::new(format!("{}", corner.index)).narrow(true).widget_instance(), + TextLabel::new(format!("{}", corner.point_id.inner())).narrow(true).widget_instance(), + TextLabel::new(format!("{}", corner.position)).narrow(true).widget_instance(), + corner + .color + .value_widgets(PathStep::Element(corner.index), data) + .into_iter() + .next() + .expect("Color always provides one value widget"), + ] + })); + + vec![LayoutGroup::table(rows, false)] + } +} + macro_rules! impl_table_item_layout_for_number { ($($ty:ty => $type_name:literal),* $(,)?) => { $( @@ -1214,6 +1247,7 @@ macro_rules! known_item_types { List>, List, List, + List, List, List, Gradient, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 2a3fef21cb9..482733610cc 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -8,6 +8,7 @@ use graphene_std::Color; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::vector::MeshGradientSurface; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, VectorModificationType}; @@ -30,6 +31,10 @@ pub enum GraphOperationMessage { gradient_settings: GradientSettings, transform: DAffine2, }, + FillMeshGradientSet { + layer: LayerNodeIdentifier, + mesh_gradient: MeshGradientSurface, + }, BlendingFillSet { layer: LayerNodeIdentifier, fill: f64, @@ -75,6 +80,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, gradient_interpolation: GradientInterpolation, }, + MeshGradientSet { + layer: LayerNodeIdentifier, + mesh_gradient: MeshGradientSurface, + }, OpacitySet { layer: LayerNodeIdentifier, opacity: f64, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index c3741f2c656..c25588e4b7e 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -56,6 +56,11 @@ impl MessageHandler> for modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_settings, transform); } } + GraphOperationMessage::FillMeshGradientSet { layer, mesh_gradient } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.fill_mesh_gradient_set(mesh_gradient); + } + } GraphOperationMessage::BlendingFillSet { layer, fill } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_fill_set(fill); @@ -111,6 +116,11 @@ impl MessageHandler> for modify_inputs.gradient_interpolation_set(gradient_interpolation); } } + GraphOperationMessage::MeshGradientSet { layer, mesh_gradient } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.mesh_gradient_set(mesh_gradient); + } + } GraphOperationMessage::OpacitySet { layer, opacity } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_set(opacity); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 3149b1d7170..30fdc7276c7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{ - ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, + ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, get_upstream_mesh_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, }; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; @@ -16,6 +16,7 @@ use graph_craft::{ProtoNodeIdentifier, list}; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::vector::MeshGradientSurface; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -586,6 +587,39 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); } + /// Write the mesh gradient to the Fill node's direct value, adding a 'Fill' node to the layer when it has none. + pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { + let existing_fill_node_id = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, false); + let Some(fill_node_id) = existing_fill_node_id.or_else(|| self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true)) else { + return; + }; + + self.set_input_with_refresh( + InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupMeshGradientInput), + NodeInput::value(TaggedValue::MeshGradient(mesh_gradient.clone()), false), + true, + ); + self.set_input_with_refresh( + InputConnector::node(fill_node_id, graphene_std::vector::fill::PaintInput), + NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), + false, + ); + + if existing_fill_node_id.is_none() { + self.restore_default_stroke_order(); + } + } + + /// Write the mesh gradient to the Mesh Gradient Value node feeding the layer. + pub fn mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { + let Some(output_layer) = self.get_output_layer() else { return }; + let Some(mesh_gradient_value_id) = get_upstream_mesh_gradient_value_node_id(output_layer, self.network_interface) else { + return; + }; + let input_connector = InputConnector::node(mesh_gradient_value_id, graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::MeshGradientInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), false); + } + /// Write the gradient stops to the 'Gradient Value' node feeding the layer. pub fn gradient_stops_set(&mut self, stops: Gradient) { let Some(output_layer) = self.get_output_layer() else { return }; 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 c8e38735e43..effa7413d5e 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -17,6 +17,7 @@ use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput}; use graph_craft::{Type, concrete}; use graphene_std::animation::RealTimeMode; +use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ @@ -32,8 +33,8 @@ use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, StrokeAlign, StrokeCap, StrokeJoin, - build_transform_with_y_preservation, + FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, MeshGradientSurface, StrokeAlign, + StrokeCap, StrokeJoin, build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; @@ -612,7 +613,20 @@ pub fn footprint_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg } pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widgets: &mut Vec) -> LayoutGroup { - let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + let ParameterWidgetsInfo { node_id, index, .. } = parameter_widgets_info; + + let store = update_value_at_index(|transform: &DAffine2| TaggedValue::DAffine2(*transform), node_id, index); + transform_widget_custom(parameter_widgets_info, extra_widgets, None, move |transform| store(&transform)) +} + +pub fn transform_widget_custom( + parameter_widgets_info: ParameterWidgetsInfo, + extra_widgets: &mut Vec, + displayed: Option, + store: impl Fn(DAffine2) -> Message + 'static + Send + Sync, +) -> LayoutGroup { + let ParameterWidgetsInfo { document_node, index, .. } = parameter_widgets_info; + let store = std::sync::Arc::new(store); let mut location_widgets = start_widgets(¶meter_widgets_info); location_widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); @@ -631,7 +645,12 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg return Vec::new().into(); }; - let widgets = if let Some(&TaggedValue::DAffine2(transform)) = input.as_non_exposed_value() { + let stored = match input.as_non_exposed_value() { + Some(&TaggedValue::DAffine2(transform)) => Some(transform), + _ => None, + }; + + let widgets = if let Some(transform) = stored.map(|stored| displayed.unwrap_or(stored)) { let translation = transform.translation; let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); let skew_matrix = DAffine2::from_cols_array(&[1., 0., skew, 1., 0., 0.]); @@ -640,22 +659,28 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg NumberInput::new(Some(translation.x)) .label("X") .unit(" px") - .on_update(parameter_widgets_info.update_value(move |x: &NumberInput| { - let mut transform = transform; - transform.translation.x = x.value.unwrap_or(transform.translation.x); - TaggedValue::DAffine2(transform) - })) + .on_update({ + let store = store.clone(); + move |x: &NumberInput| { + let mut transform = transform; + transform.translation.x = x.value.unwrap_or(transform.translation.x); + store(transform) + } + }) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), NumberInput::new(Some(translation.y)) .label("Y") .unit(" px") - .on_update(parameter_widgets_info.update_value(move |y: &NumberInput| { - let mut transform = transform; - transform.translation.y = y.value.unwrap_or(transform.translation.y); - TaggedValue::DAffine2(transform) - })) + .on_update({ + let store = store.clone(); + move |y: &NumberInput| { + let mut transform = transform; + transform.translation.y = y.value.unwrap_or(transform.translation.y); + store(transform) + } + }) .on_commit(commit_value) .widget_instance(), ]); @@ -665,14 +690,10 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg .mode(NumberInputMode::Range) .range_min(Some(-180.)) .range_max(Some(180.)) - .on_update(update_value_at_index( - move |r: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |r: &NumberInput| store(DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance()]); @@ -680,28 +701,20 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg NumberInput::new(Some(scale.x)) .label("W") .unit("x") - .on_update(update_value_at_index( - move |w: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |w: &NumberInput| store(DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), NumberInput::new(Some(scale.y)) .label("H") .unit("x") - .on_update(update_value_at_index( - move |h: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |h: &NumberInput| store(DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance(), ]); @@ -2349,6 +2362,14 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper LayoutGroup::section(name, description, visible, pinned, expanded, node_id.0, Layout(layout)) } +/// Where the 'Fill' node places a mesh gradient on its own, mirroring the automatic fit its kernel applies while no +/// explicit mesh transform is set. The panel shows this instead of the unset input's identity, so its numbers describe +/// where the mesh actually sits and raising the placement flag leaves the mesh where it already was. +fn automatic_mesh_transform(layer: Option, context: &NodePropertiesContext) -> DAffine2 { + let bounds = layer.map_or([DVec2::ZERO, DVec2::ONE], |layer| context.network_interface.document_metadata().nonzero_bounding_box(layer)); + graphene_std::vector::style::initial_mesh_gradient_transform_for_bounding_box(bounds) +} + /// The layer that a chain node ultimately feeds, if any. Returns `None` in a nested network since the layer metadata structure /// is only loaded for the root document network, so a `LayerNodeIdentifier` can't be constructed there. fn root_layer_for_chain_node(node_id: NodeId, context: &mut NodePropertiesContext) -> Option { @@ -2382,6 +2403,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte /// Whether the transform input holds a plain value (so the "Reverse Direction" button may write to it) rather than a wire. transform_is_value: bool, }, + MeshGradient { + surface: Box, + }, Other, } @@ -2400,6 +2424,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Ok(document_node) => match document_node.input_value(PaintInput) { Some(TaggedValue::Color(color)) => ResolvedFill::Solid(Some(*color)), Some(value) if value.is_no_paint() => ResolvedFill::Solid(None), + Some(TaggedValue::MeshGradient(surface)) => ResolvedFill::MeshGradient { surface: Box::new(surface.clone()) }, Some(TaggedValue::GradientRamp(_)) => { match graph_modification_utils::read_fill_node_gradient(document_node, || { layer.map_or([DVec2::ZERO, DVec2::ONE], |layer| context.network_interface.document_metadata().nonzero_bounding_box(layer)) @@ -2419,7 +2444,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Err(_) => ResolvedFill::Other, }; - let (backup_color, backup_gradient) = match get_document_node(node_id, context) { + let (backup_color, backup_gradient, backup_mesh_gradient) = match get_document_node(node_id, context) { Ok(document_node) => { let backup_color = match document_node.input_value(BackupColorInput) { Some(&TaggedValue::Color(color)) => Some(color), @@ -2429,9 +2454,13 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Some(TaggedValue::GradientRamp(ramp)) => ramp.clone(), _ => GradientRamp::black_to_white(), }; - (backup_color, backup_stops) + let backup_mesh_gradient = match document_node.input_value(BackupMeshGradientInput) { + Some(TaggedValue::MeshGradient(mesh_gradient)) => mesh_gradient.clone(), + _ => MeshGradientSurface::default(), + }; + (backup_color, backup_stops, backup_mesh_gradient) } - Err(_) => (None, GradientRamp::black_to_white()), + Err(_) => (None, GradientRamp::black_to_white(), MeshGradientSurface::default()), }; match &fill { @@ -2457,13 +2486,14 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte let widget_value = match &fill { ResolvedFill::Solid(color) => { if let Some(color) = color { - FillChoice::::Solid(SRGBA8::from(*color)) + Some(FillChoice::::Solid(SRGBA8::from(*color))) } else { - FillChoice::::None + Some(FillChoice::::None) } } - ResolvedFill::Gradient { gradient: stops, settings, .. } => FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings)), - ResolvedFill::Other => FillChoice::::None, + ResolvedFill::Gradient { gradient: stops, settings, .. } => Some(FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings))), + ResolvedFill::MeshGradient { .. } => None, + ResolvedFill::Other => Some(FillChoice::::None), }; let solid_set_messages = move |color: Option| { @@ -2505,21 +2535,23 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte ]), }; - widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets_first_row.push( - ColorInput::default() - .value(widget_value) - .on_update(move |x: &ColorInput| match &x.value { - FillChoice::::None => solid_set_messages(None), - FillChoice::::Solid(srgba8) => { - let color = Some(Color::from(*srgba8)); - solid_set_messages(color) - } - FillChoice::::Gradient(ramp) => gradient_set_messages(GradientRamp::from(ramp)), - }) - .on_commit(commit_value) - .widget_instance(), - ); + if let Some(widget_value) = widget_value { + widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets_first_row.push( + ColorInput::default() + .value(widget_value) + .on_update(move |x: &ColorInput| match &x.value { + FillChoice::::None => solid_set_messages(None), + FillChoice::::Solid(srgba8) => { + let color = Some(Color::from(*srgba8)); + solid_set_messages(color) + } + FillChoice::::Gradient(ramp) => gradient_set_messages(GradientRamp::from(ramp)), + }) + .on_commit(commit_value) + .widget_instance(), + ); + } let mut widgets = vec![LayoutGroup::row(widgets_first_row)]; @@ -2536,19 +2568,149 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte .label("Gradient") .on_update(update_value(move |_| TaggedValue::GradientRamp(backup_gradient.clone()), node_id, PaintInput)) .on_commit(commit_value), + RadioEntryData::new("mesh-gradient") + .label("Mesh Gradient") + .on_update(update_value(move |_| TaggedValue::MeshGradient(backup_mesh_gradient.clone()), node_id, PaintInput)) + .on_commit(commit_value), ]; + let selected_index = match fill { + ResolvedFill::Gradient { .. } => 1, + ResolvedFill::MeshGradient { .. } => 2, + _ => 0, + }; row.extend_from_slice(&[ Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries) - .selected_index(Some(if matches!(fill, ResolvedFill::Gradient { .. }) { 1 } else { 0 })) - .widget_instance(), + RadioInput::new(entries).selected_index(Some(selected_index)).widget_instance(), ]); LayoutGroup::row(row) }; widgets.push(fill_type_switch); + if let ResolvedFill::MeshGradient { surface } = fill.clone() { + let surface = *surface; + let set_mesh_surface = move |surface: MeshGradientSurface| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: PaintInput::INDEX, + value: TaggedValue::MeshGradient(surface.clone()).into(), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: BackupMeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(surface).into(), + } + .into(), + ]), + }; + + let space_entries = graph_modification_utils::mesh_gradient_space_sections() + .into_iter() + .map(|section| { + section + .into_iter() + .map(|(space, metadata)| { + let surface = surface.clone(); + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(move |_| { + set_mesh_surface(MeshGradientSurface { + gradient_space: space, + ..surface.clone() + }) + }) + .on_commit(commit_value) + }) + .collect() + }) + .collect(); + + let mut space_row = vec![TextLabel::new("Space").widget_instance()]; + add_blank_assist(&mut space_row); + space_row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + DropdownInput::new(space_entries) + .selected_index(graph_modification_utils::mesh_gradient_space_index(surface.gradient_space)) + .tooltip_description("The color space the mesh interpolates its corner colors through.") + .widget_instance(), + ]); + widgets.push(LayoutGroup::row(space_row)); + + let interpolation_entries = GradientInterpolation::list() + .iter() + .map(|section| { + section + .iter() + .map(|(interpolation, metadata)| { + let interpolation = *interpolation; + let surface = surface.clone(); + + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(move |_| { + set_mesh_surface(MeshGradientSurface { + gradient_interpolation: interpolation, + ..surface.clone() + }) + }) + .on_commit(commit_value) + }) + .collect() + }) + .collect(); + + let mut interpolation_row = vec![TextLabel::new("Interpolation").widget_instance()]; + add_blank_assist(&mut interpolation_row); + interpolation_row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + DropdownInput::new(interpolation_entries) + .selected_index(Some(surface.gradient_interpolation as u32)) + .tooltip_description("The path the corners interpolate along, deciding whether the gradient jumps, turns corners, or flows smoothly through them.") + .widget_instance(), + ]); + widgets.push(LayoutGroup::row(interpolation_row)); + + // Until the mesh carries a placement of its own it rides the kernel's automatic fit, so the rows show that fit and + // the first edit promotes it to an explicit placement by raising the flag alongside the transform it writes + let placed = matches!( + get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(HasMeshTransformInput)), + Some(TaggedValue::Bool(true)) + ); + let displayed = (!placed).then(|| automatic_mesh_transform(layer, context)); + + let mut preceding_rows = Vec::new(); + let last_row = transform_widget_custom( + ParameterWidgetsInfo::new(node_id, MeshTransformInput, true, context), + &mut preceding_rows, + displayed, + move |transform| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: HasMeshTransformInput::INDEX, + value: TaggedValue::Bool(true).into(), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: MeshTransformInput::INDEX, + value: TaggedValue::DAffine2(transform).into(), + } + .into(), + ]), + }, + ); + widgets.extend(preceding_rows); + widgets.push(last_row); + } + if let ResolvedFill::Gradient { gradient_form, transform, diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 59de9f797ec..aec243d205f 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -68,7 +68,7 @@ pub fn selected_segments_for_layer(vector: &Vector, state: &SelectedLayerState) selected_segments } -fn overlay_bezier_handles(segment: PathSeg, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { +pub fn overlay_bezier_handles(segment: PathSeg, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { let segment = Affine::new(transform.to_cols_array()) * segment; let segment_start = point_to_dvec2(segment.start()); let segment_end = point_to_dvec2(segment.end()); diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 0a7cc9deb9d..0e764e04825 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -784,7 +784,7 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve"); let fill_node = &network.nodes[&node_id]; - assert_eq!(fill_node.inputs.len(), 7, "the legacy Fill should upgrade to the 7-input shape"); + assert_eq!(fill_node.inputs.len(), 10, "the legacy Fill should upgrade to the 10-input shape"); let paint = fill_node.input(graphene_std::vector::fill::PaintInput); assert!( matches!(paint, Some(graph_craft::document::NodeInput::Node { .. })), @@ -800,6 +800,21 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { matches!(transform, Some(TaggedValue::DAffine2(_))), "the transform input should hold a matrix, but became {transform:?}" ); + let backup_mesh_gradient = fill_node.input_value(graphene_std::vector::fill::BackupMeshGradientInput); + assert!( + matches!(backup_mesh_gradient, Some(TaggedValue::MeshGradient(_))), + "the backup mesh gradient input should hold a mesh gradient, but became {backup_mesh_gradient:?}" + ); + let has_mesh_transform = fill_node.input_value(graphene_std::vector::fill::HasMeshTransformInput); + assert!( + matches!(has_mesh_transform, Some(TaggedValue::Bool(false))), + "the unrelated wired fill should leave mesh placement disabled, but became {has_mesh_transform:?}" + ); + let mesh_transform = fill_node.input_value(graphene_std::vector::fill::MeshTransformInput); + assert!( + matches!(mesh_transform, Some(TaggedValue::DAffine2(transform)) if *transform == glam::DAffine2::IDENTITY), + "the unrelated wired fill should retain the default mesh placement, but became {mesh_transform:?}" + ); // The Evaluate Gradient parameter held the tuple-form stops, which parse as the ramp value with even positions elided let evaluate_gradient_node = &network.nodes[&graph_craft::document::NodeId(2)]; @@ -842,7 +857,7 @@ async fn eight_input_fill_migrates_the_spread_input_into_the_ramp() { let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve"); let fill_node = &network.nodes[&node_id]; - assert_eq!(fill_node.inputs.len(), 7, "the eight-input Fill should fold down to the 7-input shape"); + assert_eq!(fill_node.inputs.len(), 10, "the eight-input Fill should upgrade to the 10-input shape"); let paint = fill_node.input_value(graphene_std::vector::fill::PaintInput); let Some(TaggedValue::GradientRamp(ramp)) = paint else { diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c83e51518ab..f3ebbd3e259 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1777,6 +1777,18 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 7; } + // Add mesh gradient inputs to Fill. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 7 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (index, input) in old_inputs.into_iter().enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + + inputs_count = 10; + } + // Upgrade Stroke node to reorder parameters and add "Align" (#2644) if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 8 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 3b99ad1afb1..1ffb7576adb 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -48,6 +48,7 @@ pub use crate::messages::tool::tool_messages::eyedropper_tool::{EyedropperToolMe pub use crate::messages::tool::tool_messages::fill_tool::{FillToolMessage, FillToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::freehand_tool::{FreehandToolMessage, FreehandToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::gradient_tool::{GradientOptionsUpdate, GradientToolMessage, GradientToolMessageDiscriminant}; +pub use crate::messages::tool::tool_messages::mesh_gradient_tool::{MeshGradientToolMessage, MeshGradientToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::navigate_tool::{NavigateToolMessage, NavigateToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::path_tool::{PathToolMessage, PathToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::pen_tool::{PenToolMessage, PenToolMessageDiscriminant}; diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 67f5a0e6cb4..c1acb975611 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -8,11 +8,14 @@ use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, NodeId, NodeInput}; use graphene_std::Color; +use graphene_std::choice_type::{ChoiceTypeStatic, VariantMetadata}; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; -use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; +use graphene_std::vector::style::{ + FillChoice, GradientSpace, MeshGradientSurface, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box, initial_mesh_gradient_transform_for_bounding_box, +}; use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; @@ -467,6 +470,26 @@ pub fn gradient_to_viewport_transform(layer: LayerNodeIdentifier, network_interf metadata.transform_to_viewport(layer) } +/// The color spaces a mesh gradient offers, keeping the choice type's section groupings. +/// Polar spaces are not supported for a mesh gradient, since a mesh offers neither +/// a stop order to wind it along nor any guarantee that its corner loops do not wind a full turn. +pub fn mesh_gradient_space_sections() -> Vec> { + GradientSpace::list() + .iter() + .map(|section| section.iter().filter(|(space, _)| !space.is_polar()).map(|(space, metadata)| (*space, metadata)).collect::>()) + .filter(|section| !section.is_empty()) + .collect() +} + +/// The position of a space among the ones a mesh offers, which is what its dropdown selects by. +pub fn mesh_gradient_space_index(space: GradientSpace) -> Option { + mesh_gradient_space_sections() + .into_iter() + .flatten() + .position(|(candidate, _)| candidate == space) + .map(|index| index as u32) +} + /// Tooltip description for a "Reverse Direction" gradient button, phrased for the given Gradient Form. pub fn reverse_direction_tooltip_description(gradient_form: GradientForm) -> &'static str { match gradient_form { @@ -487,6 +510,56 @@ pub fn gradient_orientation_rightward(transform: glam::DAffine2) -> bool { } } +/// Try to find a "Mesh Gradient Value" node that is connected to a "Fill" node, or to a layer directly. +pub fn get_upstream_mesh_gradient_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + get_upstream_paint_value_node_id(layer, network_interface, graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::IDENTIFIER) +} + +/// A mesh gradient read back out of the graph. +pub struct MeshGradientPaint { + pub surface: MeshGradientSurface, + pub transform: DAffine2, +} + +/// Decode a 'Fill' node's direct mesh gradient value. +/// Take an explicit mesh transform when the node carries one, otherwise the automatic fit over the paint target's bounds. +pub fn read_fill_node_mesh_gradient(fill_node: &DocumentNode, bounding_box: impl FnOnce() -> [DVec2; 2]) -> Option { + use graphene_std::vector::fill; + + let TaggedValue::MeshGradient(surface) = fill_node.input(fill::PaintInput)?.as_value()? else { + return None; + }; + let has_transform = matches!(fill_node.input(fill::HasMeshTransformInput).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true))); + let transform_input = fill_node.input(fill::MeshTransformInput).and_then(|input| input.as_value()); + let transform = match (has_transform, transform_input) { + (true, Some(&TaggedValue::DAffine2(value))) => value, + (false, _) => initial_mesh_gradient_transform_for_bounding_box(bounding_box()), + _ => DAffine2::IDENTITY, + }; + + Some(MeshGradientPaint { surface: surface.clone(), transform }) +} + +/// Read the mesh gradient a layer paints with straight out of the graph. +pub fn get_mesh_gradient_paint(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface, bounding_box: impl FnOnce() -> [DVec2; 2]) -> Option { + // A Fill node holding a direct mesh gradient value decodes through the shared reader + if let Some(fill_node_id) = get_fill_node_id_with_direct_fill_input(layer, network_interface) { + let fill_node = network_interface.document_network().nodes.get(&fill_node_id)?; + return read_fill_node_mesh_gradient(fill_node, bounding_box); + } + + // Otherwise the mesh comes from a 'Mesh Gradient Value' node feeding the chain, whose placement the Fill node fits + let value_node = network_interface.document_network().nodes.get(&get_upstream_mesh_gradient_value_node_id(layer, network_interface)?)?; + let TaggedValue::MeshGradient(surface) = value_node.input(graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::MeshGradientInput)?.as_value()? else { + return None; + }; + + Some(MeshGradientPaint { + surface: surface.clone(), + transform: initial_mesh_gradient_transform_for_bounding_box(bounding_box()), + }) +} + /// Get the current fill of a layer from the closest "Fill" node. pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let TaggedValue::Color(color) = NodeGraphLayer::new(layer, network_interface).parameter_value(graphene_std::vector::fill::PaintInput)? else { diff --git a/editor/src/messages/tool/tool_message.rs b/editor/src/messages/tool/tool_message.rs index 4e8ce1e868e..5af76d68f29 100644 --- a/editor/src/messages/tool/tool_message.rs +++ b/editor/src/messages/tool/tool_message.rs @@ -22,6 +22,8 @@ pub enum ToolMessage { Fill(FillToolMessage), #[child] Gradient(GradientToolMessage), + #[child] + MeshGradient(MeshGradientToolMessage), #[child] Path(PathToolMessage), @@ -58,6 +60,7 @@ pub enum ToolMessage { ActivateToolEyedropper, ActivateToolFill, ActivateToolGradient, + ActivateToolMeshGradient, // Vector tools ActivateToolPath, ActivateToolPen, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index 2d37d474704..3180c9a9598 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -66,6 +66,7 @@ impl MessageHandler> for ToolMessageHandler ToolMessage::ActivateToolText => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text }), ToolMessage::ActivateToolFill => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Fill }), ToolMessage::ActivateToolGradient => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Gradient }), + ToolMessage::ActivateToolMeshGradient => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::MeshGradient }), ToolMessage::ActivateToolPath => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Path }), ToolMessage::ActivateToolPen => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Pen }), @@ -356,6 +357,7 @@ impl MessageHandler> for ToolMessageHandler ActivateToolEyedropper, ActivateToolFill, ActivateToolGradient, + ActivateToolMeshGradient, ActivateToolPath, ActivateToolPen, diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs new file mode 100644 index 00000000000..34e8cbb2c5c --- /dev/null +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -0,0 +1,1155 @@ +use super::tool_prelude::*; +use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE}; +use crate::messages::portfolio::document::overlays::utility_functions::overlay_bezier_handles; +use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::tool::common_functionality::auto_panning::AutoPanning; +use crate::messages::tool::common_functionality::graph_modification_utils::{ + self, MeshGradientPaint, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_mesh_gradient_paint, get_upstream_mesh_gradient_value_node_id, +}; +use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; +use crate::messages::tool::utility_types::ToolRefreshOptions; +use graphene_std::color::SRGBA8; +use graphene_std::raster::color::Color; +use graphene_std::vector::algorithms::util::pathseg_tangent; +use graphene_std::vector::misc::{BezierHandles, dvec2_to_point, pathseg_points, point_to_dvec2, segment_to_handles}; +use graphene_std::vector::style::{GradientSpace, MeshGradientSurface}; +use graphene_std::vector::{GradientInterpolation, HandleId, MeshGradient, SegmentId}; +use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; + +#[derive(Default, ExtractField)] +pub struct MeshGradientTool { + fsm_state: MeshGradientToolFsmState, + data: MeshGradientToolData, + options: MeshGradientOptions, +} + +pub struct MeshGradientOptions { + space: GradientSpace, + interpolation: GradientInterpolation, +} + +impl Default for MeshGradientOptions { + fn default() -> Self { + let MeshGradientSurface { + gradient_space, + gradient_interpolation, + .. + } = MeshGradientSurface::default(); + Self { + space: gradient_space, + interpolation: gradient_interpolation, + } + } +} + +#[impl_message(Message, ToolMessage, MeshGradient)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum MeshGradientToolMessage { + // Standard messages + Abort, + Overlays { context: OverlayContext }, + SelectionChanged, + + // Tool-specific messages + DeleteEdge, + DoubleClick, + PointerDown, + PointerMove { constrain_axis: Key }, + PointerOutsideViewport { constrain_axis: Key }, + PointerUp, + StartTransactionForColorStop, + CommitTransactionForColorStop, + CloseStopColorPicker, + UpdateStopColor { color: Color }, + UpdateOptions { options: MeshGradientOptionsUpdate }, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] +pub enum MeshGradientOptionsUpdate { + Space(GradientSpace), + Interpolation(GradientInterpolation), +} + +impl ToolMetadata for MeshGradientTool { + fn icon_name(&self) -> String { + "GeneralGradientTool".into() + } + fn tooltip_label(&self) -> String { + "Mesh Gradient Tool".into() + } + fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType { + ToolType::MeshGradient + } +} + +#[message_handler_data] +impl<'a> MessageHandler> for MeshGradientTool { + fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + match message { + ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateOptions { options }) => { + match options { + MeshGradientOptionsUpdate::Space(space) => self.options.space = space, + MeshGradientOptionsUpdate::Interpolation(interpolation) => self.options.interpolation = interpolation, + } + + // Write back only the setting that actually changed, so a layer whose other setting differs keeps it + apply_mesh_gradient_options(context, responses, |surface| match &options { + MeshGradientOptionsUpdate::Space(space) => surface.gradient_space = *space, + MeshGradientOptionsUpdate::Interpolation(interpolation) => surface.gradient_interpolation = *interpolation, + }); + self.refresh_options(responses); + } + ToolMessage::MeshGradient(MeshGradientToolMessage::SelectionChanged) => { + if let Some(surface) = first_selected_mesh_gradient_surface(context.document) { + self.options.space = surface.gradient_space; + self.options.interpolation = surface.gradient_interpolation; + self.refresh_options(responses); + } + self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); + } + ToolMessage::MeshGradient(MeshGradientToolMessage::StartTransactionForColorStop) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + } + responses.add(DocumentMessage::StartTransaction); + self.data.color_picker_transaction_open = true; + } + ToolMessage::MeshGradient(MeshGradientToolMessage::CommitTransactionForColorStop) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + self.data.color_picker_transaction_open = false; + } + } + ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateStopColor { color }) => { + let Some(selected_mesh) = self.data.selected_mesh.as_mut() else { return }; + + if let MeshGradientTarget::Corner { corner_index, .. } = selected_mesh.target + && self.data.color_picker_editing_color_stop == Some(corner_index) + && selected_mesh.surface.mesh.set_corner_color(corner_index, color).is_some() + { + selected_mesh.update_gradient_in_graph(responses); + responses.add(PropertiesPanelMessage::Refresh); + responses.add(OverlaysMessage::Draw); + } + } + ToolMessage::MeshGradient(MeshGradientToolMessage::CloseStopColorPicker) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + self.data.color_picker_transaction_open = false; + } + self.data.color_picker_editing_color_stop = None; + } + _ => { + self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); + + if let Some(surface) = first_selected_mesh_gradient_surface(context.document) { + let mut needs_refresh = false; + if self.options.space != surface.gradient_space { + self.options.space = surface.gradient_space; + needs_refresh = true; + } + if self.options.interpolation != surface.gradient_interpolation { + self.options.interpolation = surface.gradient_interpolation; + needs_refresh = true; + } + if needs_refresh { + self.refresh_options(responses); + } + } + } + } + } + + fn actions(&self) -> ActionList { + actions!(MeshGradientToolMessageDiscriminant; + UpdateOptions, + PointerDown, + PointerUp, + PointerMove, + DoubleClick, + DeleteEdge, + Abort, + ) + } +} + +impl LayoutHolder for MeshGradientTool { + fn layout(&self) -> Layout { + let space_entries = graph_modification_utils::mesh_gradient_space_sections() + .into_iter() + .map(|section| { + section + .into_iter() + .map(|(space, metadata)| { + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(move |_| { + MeshGradientToolMessage::UpdateOptions { + options: MeshGradientOptionsUpdate::Space(space), + } + .into() + }) + }) + .collect() + }) + .collect(); + let space = DropdownInput::new(space_entries) + .selected_index(graph_modification_utils::mesh_gradient_space_index(self.options.space)) + .tooltip_description("The color space the mesh interpolates its corner colors through.") + .widget_instance(); + + let interpolation_entries = MenuListEntry::sections_from_choice_type(|interpolation| { + MeshGradientToolMessage::UpdateOptions { + options: MeshGradientOptionsUpdate::Interpolation(interpolation), + } + .into() + }); + let interpolation = DropdownInput::new(interpolation_entries) + .selected_index(Some(self.options.interpolation as u32)) + .tooltip_description("The path the corners interpolate along, deciding whether the gradient jumps, turns corners, or flows smoothly through them.") + .widget_instance(); + + Layout(vec![LayoutGroup::row(vec![ + TextLabel::new("Space").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + space, + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new("Interpolation").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + interpolation, + ])]) + } +} + +/// The mesh gradient a layer paints. +fn layer_mesh_gradient_paint(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> Option { + get_mesh_gradient_paint(layer, &document.network_interface, || document.metadata().nonzero_bounding_box(layer)) +} + +/// Returns the first mesh gradient painted by the selection, paired with the settings riding alongside it. +fn first_selected_mesh_gradient_surface(document: &DocumentMessageHandler) -> Option { + document + .network_interface + .selected_nodes() + .selected_visible_layers(&document.network_interface) + .find_map(|layer| layer_mesh_gradient_paint(document, layer).map(|paint| paint.surface)) +} + +/// Whether the layer's fill already paints a mesh gradient. +fn layer_paints_mesh_gradient(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> bool { + layer_mesh_gradient_paint(document, layer).is_some() +} + +/// Rewrites the settings of the first mesh gradient of every selected layer, leaving its geometry and colors alone. +fn apply_mesh_gradient_options(context: &mut ToolActionMessageContext, responses: &mut VecDeque, update: impl Fn(&mut MeshGradientSurface)) { + let document = &context.document; + let selected_layers: Vec<_> = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); + + let mut transaction_started = false; + for layer in selected_layers { + let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { + continue; + }; + let Some(mut surface) = layer_mesh_gradient_paint(document, layer).map(|paint| paint.surface) else { + continue; + }; + update(&mut surface); + + if !transaction_started { + responses.add(DocumentMessage::StartTransaction); + transaction_started = true; + } + responses.add(match source { + GradientSource::Direct => GraphOperationMessage::FillMeshGradientSet { layer, mesh_gradient: surface }, + GradientSource::Chain => GraphOperationMessage::MeshGradientSet { layer, mesh_gradient: surface }, + }); + } + + if transaction_started { + responses.add(DocumentMessage::EndTransaction); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MeshGradientToolFsmState { + Ready { + hovering: MeshGradientHoverTarget, + selected: MeshGradientSelectedTarget, + }, + Dragging, +} + +impl Default for MeshGradientToolFsmState { + fn default() -> Self { + Self::Ready { + hovering: MeshGradientHoverTarget::None, + selected: MeshGradientSelectedTarget::None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct SelectedMeshGradient { + layer: LayerNodeIdentifier, + surface: MeshGradientSurface, + mesh_to_document: DAffine2, + source: GradientSource, + target: MeshGradientTarget, +} + +impl SelectedMeshGradient { + pub fn update_gradient_in_graph(&mut self, responses: &mut VecDeque) { + let message = match self.source { + GradientSource::Direct => GraphOperationMessage::FillMeshGradientSet { + layer: self.layer, + mesh_gradient: self.surface.clone(), + }, + GradientSource::Chain => GraphOperationMessage::MeshGradientSet { + layer: self.layer, + mesh_gradient: self.surface.clone(), + }, + }; + responses.add(message); + } + + fn update_color_picker_position(&self, corner_index: usize, document_to_viewport: DAffine2, responses: &mut VecDeque) -> bool { + let Some(corner) = self.surface.mesh.corners().find(|corner| corner.index == corner_index) else { + return false; + }; + let mesh_to_viewport = document_to_viewport * self.mesh_to_document; + let position = mesh_to_viewport.transform_point2(corner.position).into(); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + true + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GradientSource { + Direct, + Chain, +} + +fn resolve_mesh_gradient_source(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + if get_fill_node_id_with_direct_fill_input(layer, network_interface).is_some() { + Some(GradientSource::Direct) + } else if get_upstream_mesh_gradient_value_node_id(layer, network_interface).is_some() { + Some(GradientSource::Chain) + } else { + None + } +} + +fn approximate_valid_region_bounds(initial_position: DVec2, [min, max]: [DVec2; 2], mut is_valid: impl FnMut(DVec2) -> bool) -> Option<[DVec2; 2]> { + const SUBDIVISIONS: usize = 12; + + let mut x_samples = (0..=SUBDIVISIONS).map(|index| min.x + (max.x - min.x) * index as f64 / SUBDIVISIONS as f64).collect::>(); + let mut y_samples = (0..=SUBDIVISIONS).map(|index| min.y + (max.y - min.y) * index as f64 / SUBDIVISIONS as f64).collect::>(); + x_samples.push(initial_position.x); + y_samples.push(initial_position.y); + x_samples.sort_by(f64::total_cmp); + y_samples.sort_by(f64::total_cmp); + x_samples.dedup(); + y_samples.dedup(); + + let columns = x_samples.len(); + let rows = y_samples.len(); + let seed_column = x_samples.iter().position(|&x| x == initial_position.x)?; + let seed_row = y_samples.iter().position(|&y| y == initial_position.y)?; + let seed_index = seed_row * columns + seed_column; + + let valid_samples = y_samples.iter().flat_map(|&y| x_samples.iter().map(move |&x| DVec2::new(x, y))).map(&mut is_valid).collect::>(); + + if !valid_samples[seed_index] { + return None; + } + + let mut visited = vec![false; rows * columns]; + let mut queue = VecDeque::from([seed_index]); + let mut bounds_min = initial_position; + let mut bounds_max = initial_position; + + while let Some(index) = queue.pop_front() { + if visited[index] || !valid_samples[index] { + continue; + } + visited[index] = true; + + let row = index / columns; + let column = index % columns; + let position = DVec2::new(x_samples[column], y_samples[row]); + bounds_min = bounds_min.min(position); + bounds_max = bounds_max.max(position); + + if row > 0 { + queue.push_back(index - columns); + } + if row + 1 < rows { + queue.push_back(index + columns); + } + if column > 0 { + queue.push_back(index - 1); + } + if column + 1 < columns { + queue.push_back(index + 1); + } + } + + Some([bounds_min, bounds_max]) +} + +/// Walks back from `target` toward the valid region's center for the furthest position that keeps the mesh free of foldovers. +fn constrain_to_valid_region( + target: DVec2, + valid_region_center: &mut Option, + resolve_center: impl FnOnce() -> DVec2, + candidate: impl Fn(DVec2) -> Option, +) -> Option { + if let Some(gradient) = candidate(target) { + return Some(gradient); + } + + const BINARY_SEARCH_ITERATIONS: usize = 12; + let center = *valid_region_center.get_or_insert_with(resolve_center); + let mut valid_t = 0.; + let mut invalid_t = 1.; + let mut valid_gradient = candidate(center)?; + + for _ in 0..BINARY_SEARCH_ITERATIONS { + let mid_t = (valid_t + invalid_t) / 2.; + let mid_position = center.lerp(target, mid_t); + + if let Some(gradient) = candidate(mid_position) { + valid_t = mid_t; + valid_gradient = gradient; + } else { + invalid_t = mid_t; + } + } + + Some(valid_gradient) +} + +#[derive(Clone, Debug, PartialEq)] +enum MeshGradientTarget { + Corner { + corner_index: usize, + initial_mouse: DVec2, + initial_corner: DVec2, + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, + }, + Segment { + segment_id: SegmentId, + initial_mouse: DVec2, + initial_handles: [DVec2; 2], + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, + }, + Handle { + handle_id: HandleId, + initial_mouse: DVec2, + initial_handle: DVec2, + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, + }, +} + +impl ToolTransition for MeshGradientTool { + fn event_to_message_map(&self) -> EventToMessageMap { + EventToMessageMap { + tool_abort: Some(MeshGradientToolMessage::Abort.into()), + selection_changed: Some(MeshGradientToolMessage::SelectionChanged.into()), + overlay_provider: Some(|context| MeshGradientToolMessage::Overlays { context }.into()), + ..Default::default() + } + } +} + +#[derive(Clone, Debug, Default)] +struct MeshGradientToolData { + selected_mesh: Option, + snap_manager: SnapManager, + drag_start: DVec2, + /// The pointer-down position before snapping (document space), used to detect whether the mouse moved between the press and a double-click. + drag_start_unsnapped: DVec2, + auto_panning: AutoPanning, + auto_pan_shift: DVec2, + color_picker_editing_color_stop: Option, + color_picker_transaction_open: bool, +} + +impl Fsm for MeshGradientToolFsmState { + type ToolData = MeshGradientToolData; + type ToolOptions = MeshGradientOptions; + + fn transition( + self, + event: ToolMessage, + tool_data: &mut Self::ToolData, + tool_action_data: &mut ToolActionMessageContext, + tool_options: &Self::ToolOptions, + responses: &mut VecDeque, + ) -> Self { + let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data; + let ToolMessage::MeshGradient(event) = event else { return self }; + + match (self, event) { + (_, MeshGradientToolMessage::Overlays { context: mut overlay_context }) => { + let metadata = document.metadata(); + let mut hovered_segment: Option<(f64, DVec2, DVec2)> = None; + let mut hovering_corner = false; + + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let Some(paint) = layer_mesh_gradient_paint(document, layer) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + { + let mesh = &paint.surface.mesh; + + let mesh_to_viewport = layer_to_viewport * paint.transform; + let geometry = mesh.geometry(); + + // Render the mesh geometry's outline in the same manner as the path tool does + if overlay_context.visibility_settings.path() { + overlay_context.outline_vector(geometry, mesh_to_viewport); + } + + if let Some(selected_segment_id) = tool_data.selected_mesh.as_ref().and_then(|selected_mesh| { + if selected_mesh.layer != layer { + return None; + } + match selected_mesh.target { + MeshGradientTarget::Segment { segment_id, .. } => Some(segment_id), + _ => None, + } + }) && let Some(edge) = mesh.edges().find(|edge| edge.segment_id == selected_segment_id) + { + overlay_context.outline_select_bezier(edge.segment, mesh_to_viewport); + } + + if overlay_context.visibility_settings.handles() { + for (segment_id, segment, _, _) in geometry.segment_iter() { + overlay_bezier_handles(segment, segment_id, mesh_to_viewport, |_| false, &mut overlay_context); + } + } + + if overlay_context.visibility_settings.anchors() { + for &position in geometry.point_domain.positions() { + overlay_context.manipulator_anchor(mesh_to_viewport.transform_point2(position), false, None); + } + } + + // Then, place the color stop gizmos for all mesh corners + for corner in mesh.corners() { + let position = mesh_to_viewport.transform_point2(corner.position); + let color = SRGBA8::from(corner.color).to_css_hex(); + hovering_corner |= position.distance_squared(input.mouse.position) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + + let is_selected = tool_data.selected_mesh.as_ref().is_some_and(|selected_mesh| { + matches!( + selected_mesh.target, + MeshGradientTarget::Corner{corner_index, ..} + if selected_mesh.layer == layer + && corner_index == corner.index + ) + }); + + let emphasis = if is_selected { GizmoEmphasis::Active } else { GizmoEmphasis::Regular }; + + overlay_context.gradient_color_stop(position, emphasis, &color, false); + } + + // Display the normal line overray when the mouse is on a edge + if !hovering_corner { + let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + for edge in mesh.edges() { + let t = edge.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + let closest_local = point_to_dvec2(edge.segment.eval(t)); + let closest_viewport = mesh_to_viewport.transform_point2(closest_local); + let distance_squared = closest_viewport.distance_squared(input.mouse.position); + + if distance_squared > SEGMENT_INSERTION_DISTANCE.powi(2) { + continue; + } + + let tangent_local = pathseg_tangent(edge.segment, t); + let Some(tangent_viewport) = mesh_to_viewport.transform_vector2(tangent_local).try_normalize() else { + continue; + }; + let normal_viewport = tangent_viewport.perp(); + if hovered_segment.as_ref().is_none_or(|(closest_distance, _, _)| distance_squared < *closest_distance) { + hovered_segment = Some((distance_squared, closest_viewport, normal_viewport)); + } + } + } + } + } + + if matches!(self, MeshGradientToolFsmState::Ready { .. }) + && !hovering_corner + && let Some((_, point, normal)) = hovered_segment + { + overlay_context.line(point - normal * SEGMENT_OVERLAY_SIZE, point + normal * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None); + } + + tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); + + if let Some(corner_index) = tool_data.color_picker_editing_color_stop + && let Some(selected_mesh) = tool_data.selected_mesh.as_ref() + { + selected_mesh.update_color_picker_position(corner_index, metadata.document_to_viewport, responses); + } + + match self { + MeshGradientToolFsmState::Ready { selected, .. } => MeshGradientToolFsmState::Ready { + hovering: if hovering_corner { + MeshGradientHoverTarget::Corner + } else if hovered_segment.is_some() { + MeshGradientHoverTarget::Segment + } else { + MeshGradientHoverTarget::None + }, + selected, + }, + _ => self, + } + } + (state, MeshGradientToolMessage::SelectionChanged) => { + if matches!(state, MeshGradientToolFsmState::Dragging) { + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); + } else if tool_data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + } + tool_data.color_picker_transaction_open = false; + tool_data.color_picker_editing_color_stop = None; + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::default() + } + + (_state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { + let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; + let MeshGradientTarget::Segment { segment_id, .. } = selected_mesh.target else { return self }; + let mut mesh = selected_mesh.surface.mesh.clone(); + if mesh.remove_edge(segment_id).is_none() { + return self; + } + selected_mesh.surface.mesh = mesh; + + responses.add(DocumentMessage::StartTransaction); + selected_mesh.update_gradient_in_graph(responses); + responses.add(DocumentMessage::EndTransaction); + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::Ready { + hovering: MeshGradientHoverTarget::None, + selected: MeshGradientSelectedTarget::None, + } + } + + (_, MeshGradientToolMessage::DoubleClick) => { + // Ignore when dragging + let drag_start_viewport = document.metadata().document_to_viewport.transform_point2(tool_data.drag_start_unsnapped); + if input.mouse.position.distance(drag_start_viewport) > DRAG_THRESHOLD { + return self; + } + + let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; + let document_to_viewport = document.metadata().document_to_viewport; + + match selected_mesh.target { + // Display color picker when the mesh corner color gizmo is double clicked + MeshGradientTarget::Corner { corner_index, .. } => { + if !selected_mesh.update_color_picker_position(corner_index, document_to_viewport, responses) { + return self; + } + + tool_data.color_picker_editing_color_stop = Some(corner_index); + } + MeshGradientTarget::Segment { segment_id, .. } => { + let mesh_to_viewport = document_to_viewport * selected_mesh.mesh_to_document; + let Some(segment) = selected_mesh.surface.mesh.edges().find(|edge| edge.segment_id == segment_id) else { + return self; + }; + let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + let time = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + if selected_mesh + .surface + .mesh + .insert_grid_line(segment.segment_id, selected_mesh.surface.gradient_space, selected_mesh.surface.gradient_interpolation, time) + .is_none() + { + return self; + } + + responses.add(DocumentMessage::StartTransaction); + selected_mesh.update_gradient_in_graph(responses); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + + // Inserting a grid line removes the selected segment, so discard its now-stale ID and deletion hint. + tool_data.selected_mesh = None; + return MeshGradientToolFsmState::default(); + } + _ => {} + }; + + self + } + + (MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::PointerDown) => { + let metadata = document.metadata(); + let document_to_viewport = metadata.document_to_viewport; + let mouse = input.mouse.position; + let document_mouse = document_to_viewport.inverse().transform_point2(mouse); + tool_data.drag_start = document_mouse; + tool_data.drag_start_unsnapped = document_mouse; + tool_data.auto_pan_shift = DVec2::ZERO; + let tolerance_squared = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let Some(paint) = layer_mesh_gradient_paint(document, layer) else { + continue; + }; + let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + { + let gradient = &paint.surface.mesh; + + let mesh_to_viewport = layer_to_viewport * paint.transform; + let mesh_to_document = document_to_viewport.inverse() * mesh_to_viewport; + let local_mouse = mesh_to_viewport.inverse().transform_point2(mouse); + + // Change the corner position. Hit check on corners should have higher priority than the segments. + for corner in gradient.corners() { + let corner_in_viewport = mesh_to_viewport.transform_point2(corner.position); + let distance_squared = corner_in_viewport.distance_squared(mouse); + + if distance_squared < tolerance_squared { + responses.add(DocumentMessage::StartTransaction); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + surface: paint.surface.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Corner { + corner_index: corner.index, + initial_mouse: local_mouse, + initial_corner: corner.position, + valid_region_center: None, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + + let mut closest_handle: Option<(HandleId, DVec2, f64)> = None; + let hidden_distance_squared = HIDE_HANDLE_DISTANCE.powi(2); + + // Change the handle position. + for (segment_id, segment, _, _) in gradient.geometry().segment_iter() { + let mut consider_handle = |handle_id: HandleId, handle: DVec2, anchor: DVec2, _other_anchor: Option| { + let handle_viewport = mesh_to_viewport.transform_point2(handle); + let anchor_viewport = mesh_to_viewport.transform_point2(anchor); + + // Ignore handles that is not displayed in the overlay + if handle_viewport.distance_squared(anchor_viewport) < hidden_distance_squared { + return; + } + + let distance_squared = handle_viewport.distance_squared(mouse); + if distance_squared < tolerance_squared && closest_handle.as_ref().is_none_or(|(_, _, closest_distance)| distance_squared < *closest_distance) { + closest_handle = Some((handle_id, handle, distance_squared)); + } + }; + + let segment_start = point_to_dvec2(segment.start()); + let segment_end = point_to_dvec2(segment.end()); + + match segment_to_handles(&segment) { + BezierHandles::Linear => {} + BezierHandles::Quadratic { handle } => { + consider_handle(HandleId::primary(segment_id), handle, segment_start, Some(segment_end)); + } + BezierHandles::Cubic { handle_start, handle_end } => { + consider_handle(HandleId::primary(segment_id), handle_start, segment_start, None); + consider_handle(HandleId::end(segment_id), handle_end, segment_end, None); + } + } + } + + // Resolved only after every segment has been offered, so the nearest-wins comparison spans the whole mesh + if let Some((handle_id, initial_handle, _)) = closest_handle { + responses.add(DocumentMessage::StartTransaction); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + surface: paint.surface.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Handle { + handle_id, + initial_mouse: local_mouse, + initial_handle, + valid_region_center: None, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + + for edge in gradient.edges() { + // Mold the mesh edge by dragging the segment directly while keeping the corners fixed. + let t = edge.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t; + let closest_position_in_viewport = mesh_to_viewport.transform_point2(point_to_dvec2(edge.segment.eval(t))); + let distance_squared = closest_position_in_viewport.distance_squared(mouse); + + if distance_squared < tolerance_squared { + let points = pathseg_points(edge.segment); + + let handles = match (points.p1, points.p2) { + (Some(p1), Some(p2)) => [p1, p2], + (Some(control), None) | (None, Some(control)) => [points.p0 + (control - points.p0) * 2. / 3., points.p3 + (control - points.p3) * 2. / 3.], + (None, None) => [points.p0 + (points.p3 - points.p0) / 3., points.p3 + (points.p0 - points.p3) / 3.], + }; + + responses.add(DocumentMessage::StartTransaction); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + surface: paint.surface.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Segment { + segment_id: edge.segment_id, + initial_mouse: local_mouse, + initial_handles: handles, + valid_region_center: None, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + } + } + + // No gizmo was under the cursor, so the click falls through to the layer beneath it + let Some(layer) = document.click_based_on_position(document_mouse) else { return self }; + if NodeGraphLayer::is_raster_layer(layer, &mut document.network_interface) { + return self; + } + + if !document.network_interface.selected_nodes().selected_layers_contains(layer, document.metadata()) { + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); + } + + // A layer already painted with a mesh gradient is only selected, leaving its mesh as it stands to be edited + if layer_paints_mesh_gradient(document, layer) { + responses.add(OverlaysMessage::Draw); + return self; + } + + // Otherwise the layer's paint, whatever it was, gives way to a fresh mesh gradient held as the Fill node's value + responses.add(DocumentMessage::StartTransaction); + responses.add(GraphOperationMessage::FillMeshGradientSet { + layer, + mesh_gradient: MeshGradientSurface { + mesh: MeshGradient::default(), + gradient_space: tool_options.space, + gradient_interpolation: tool_options.interpolation, + }, + }); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + + self + } + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis }) => { + let MeshGradientToolData { + selected_mesh, + snap_manager, + auto_panning, + auto_pan_shift, + .. + } = tool_data; + let Some(selected_mesh) = selected_mesh.as_mut() else { return self }; + + let document_to_viewport = document.metadata().document_to_viewport; + let mesh_to_document = selected_mesh.mesh_to_document; + let mut mesh_to_viewport = document_to_viewport * mesh_to_document; + mesh_to_viewport.translation += *auto_pan_shift; + *auto_pan_shift = DVec2::ZERO; + + let current_local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + let snap_data = SnapData::new(document, input, viewport); + let snap_angle = input.keyboard.get(constrain_axis as usize); + let mut snap_local_point = |origin_local: DVec2, local_point: DVec2| { + if snap_angle { + snap_manager.clear_indicator(); + + let origin_viewport = mesh_to_viewport.transform_point2(origin_local); + let local_point_viewport = mesh_to_viewport.transform_point2(local_point); + let delta = origin_viewport - local_point_viewport; + let length = delta.length(); + if length <= f64::EPSILON { + return local_point; + } + + let snap_resolution = LINE_ROTATE_SNAP_ANGLE.to_radians(); + let angle = (-delta.angle_to(DVec2::X) / snap_resolution).round() * snap_resolution; + let rotated = DVec2::new(length * angle.cos(), length * angle.sin()); + return mesh_to_viewport.inverse().transform_point2(origin_viewport - rotated); + } + + let document_point = mesh_to_document.transform_point2(local_point); + let point = SnapCandidatePoint::gradient_handle(document_point); + let snapped = snap_manager.free_snap(&snap_data, &point, SnapTypeConfiguration::default()); + let local_point = if snapped.is_snapped() { + mesh_to_document.inverse().transform_point2(snapped.snapped_point_document) + } else { + local_point + }; + snap_manager.update_indicator(snapped); + local_point + }; + + match &mut selected_mesh.target { + MeshGradientTarget::Corner { + corner_index, + initial_mouse, + initial_corner, + valid_region_center, + } => { + let corner_index = *corner_index; + let initial_mouse = *initial_mouse; + let initial_corner = *initial_corner; + let desired_position = initial_corner + current_local_mouse - initial_mouse; + let snapped_local_mouse = snap_local_point(initial_corner, desired_position); + let mesh = &selected_mesh.surface.mesh; + let candidate_gradient = |position| { + let mut gradient = mesh.clone(); + gradient.set_corner_position(corner_index, position)?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_corner, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_corner) + }; + // let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient); + let mut gradient = mesh.clone(); + gradient.set_corner_position(corner_index, snapped_local_mouse); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + } + MeshGradientTarget::Segment { + segment_id, + initial_mouse: initial_local_mouse, + initial_handles, + valid_region_center, + } => { + let snapped_local_mouse = snap_local_point(*initial_local_mouse, current_local_mouse); + let initial_local_mouse = *initial_local_mouse; + let mesh = &selected_mesh.surface.mesh; + let candidate_gradient = |mouse_position: DVec2| { + let delta = mouse_position - initial_local_mouse; + let mut gradient = mesh.clone(); + gradient.set_edge_handles( + *segment_id, + BezierHandles::Cubic { + handle_start: initial_handles[0] + delta, + handle_end: initial_handles[1] + delta, + }, + )?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_local_mouse, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_local_mouse) + }; + + // if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient) { + let mut gradient = mesh.clone(); + let delta = snapped_local_mouse - initial_local_mouse; + gradient.set_edge_handles( + *segment_id, + BezierHandles::Cubic { + handle_start: initial_handles[0] + delta, + handle_end: initial_handles[1] + delta, + }, + ); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + // } + } + MeshGradientTarget::Handle { + handle_id, + initial_mouse, + initial_handle, + valid_region_center, + } => { + let delta = current_local_mouse - *initial_mouse; + let new_handle_position = snap_local_point(*initial_handle, *initial_handle + delta); + let initial_handle = *initial_handle; + let mesh = &selected_mesh.surface.mesh; + let candidate_gradient = |position| { + let mut gradient = mesh.clone(); + gradient.set_handle_position(*handle_id, position)?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_handle, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_handle) + }; + + // if let Some(gradient) = constrain_to_valid_region(new_handle_position, valid_region_center, resolve_center, candidate_gradient) { + let mut gradient = mesh.clone(); + gradient.set_handle_position(*handle_id, new_handle_position); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + // } + } + }; + + // Auto-panning + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis }.into(), + ]; + auto_panning.setup_by_mouse_position(input, viewport, &messages, responses); + + MeshGradientToolFsmState::Dragging + } + + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerUp) => { + let Some(selected_mesh) = tool_data.selected_mesh.as_ref() else { return self }; + let selected = match selected_mesh.target { + MeshGradientTarget::Corner { .. } => MeshGradientSelectedTarget::Corner, + MeshGradientTarget::Segment { .. } => MeshGradientSelectedTarget::Segment, + MeshGradientTarget::Handle { .. } => MeshGradientSelectedTarget::Handle, + }; + + responses.add(DocumentMessage::EndTransaction); + tool_data.snap_manager.cleanup(responses); + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::Ready { + hovering: MeshGradientHoverTarget::None, + selected, + } + } + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::Abort) => { + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::default() + } + + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerOutsideViewport { .. }) => { + // Auto-panning + if let Some(shift) = tool_data.auto_panning.shift_viewport(input, viewport, responses) { + tool_data.auto_pan_shift += shift; + } + + MeshGradientToolFsmState::Dragging + } + (state, MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }) => { + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis }.into(), + ]; + tool_data.auto_panning.stop(&messages, responses); + + state + } + + (state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::PointerMove { .. }) => { + responses.add(OverlaysMessage::Draw); + state + } + _ => self, + } + } + + fn update_hints(&self, responses: &mut VecDeque) { + let hint_data = match self { + MeshGradientToolFsmState::Ready { hovering, selected } => { + let mut groups = match hovering { + MeshGradientHoverTarget::None => vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Paint Layer with Mesh")])], + MeshGradientHoverTarget::Corner => vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Corner")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Edit Color")]), + ], + MeshGradientHoverTarget::Segment => vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Segment")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Mold Segment")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Insert Grid Line")]), + ], + }; + + if matches!(selected, MeshGradientSelectedTarget::Segment) { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Delete Grid Line")])); + } + + HintData(groups) + } + MeshGradientToolFsmState::Dragging => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]), + }; + + hint_data.send_layout(responses); + } + + fn update_cursor(&self, _responses: &mut VecDeque) {} +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum MeshGradientHoverTarget { + #[default] + None, + Corner, + Segment, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum MeshGradientSelectedTarget { + #[default] + None, + Corner, + Segment, + Handle, +} diff --git a/editor/src/messages/tool/tool_messages/mod.rs b/editor/src/messages/tool/tool_messages/mod.rs index bbe0d2adf7f..e1486efe999 100644 --- a/editor/src/messages/tool/tool_messages/mod.rs +++ b/editor/src/messages/tool/tool_messages/mod.rs @@ -4,6 +4,7 @@ pub mod eyedropper_tool; pub mod fill_tool; pub mod freehand_tool; pub mod gradient_tool; +pub mod mesh_gradient_tool; pub mod navigate_tool; pub mod path_tool; pub mod pen_tool; diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index 2f928e3e993..a44545ebffb 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -372,6 +372,7 @@ pub enum ToolType { Eyedropper, Fill, Gradient, + MeshGradient, // Vector tool group Path, @@ -422,6 +423,7 @@ fn list_tools_in_groups() -> Vec> { ToolRole::Normal(Box::::default()), ToolRole::Normal(Box::::default()), ToolRole::Normal(Box::::default()), + ToolRole::Normal(Box::::default()), ], vec![ // Vector tool group @@ -474,6 +476,7 @@ pub fn tool_message_to_tool_type(tool_message: &ToolMessage) -> ToolType { ToolMessage::Eyedropper(_) => ToolType::Eyedropper, ToolMessage::Fill(_) => ToolType::Fill, ToolMessage::Gradient(_) => ToolType::Gradient, + ToolMessage::MeshGradient(_) => ToolType::MeshGradient, // Vector tool group ToolMessage::Path(_) => ToolType::Path, @@ -503,6 +506,7 @@ pub fn tool_type_to_activate_tool_message(tool_type: ToolType) -> ToolMessageDis ToolType::Eyedropper => ToolMessageDiscriminant::ActivateToolEyedropper, ToolType::Fill => ToolMessageDiscriminant::ActivateToolFill, ToolType::Gradient => ToolMessageDiscriminant::ActivateToolGradient, + ToolType::MeshGradient => ToolMessageDiscriminant::ActivateToolMeshGradient, // Vector tool group ToolType::Path => ToolMessageDiscriminant::ActivateToolPath, diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 96f514dc0b4..59ea9a1b24c 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -480,22 +480,31 @@ mod editor_commands { /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary). fn update_gradient_stop_color(color: SRGBA8) -> Message { - GradientToolMessage::UpdateStopColor { color: Color::from(color) }.into() + let color = Color::from(color); + Message::Batched { + messages: Box::new([GradientToolMessage::UpdateStopColor { color }.into(), MeshGradientToolMessage::UpdateStopColor { color }.into()]), + } } /// Start a new undo transaction for gradient stop color editing fn start_gradient_stop_color_transaction() -> Message { - GradientToolMessage::StartTransactionForColorStop.into() + Message::Batched { + messages: Box::new([GradientToolMessage::StartTransactionForColorStop.into(), MeshGradientToolMessage::StartTransactionForColorStop.into()]), + } } /// Commit the current gradient stop color transaction (called on pointer-up after each drag/click) fn commit_gradient_stop_color_transaction() -> Message { - GradientToolMessage::CommitTransactionForColorStop.into() + Message::Batched { + messages: Box::new([GradientToolMessage::CommitTransactionForColorStop.into(), MeshGradientToolMessage::CommitTransactionForColorStop.into()]), + } } /// Close the gradient stop color picker and commit any pending transaction fn close_gradient_stop_color_picker() -> Message { - GradientToolMessage::CloseStopColorPicker.into() + Message::Batched { + messages: Box::new([GradientToolMessage::CloseStopColorPicker.into(), MeshGradientToolMessage::CloseStopColorPicker.into()]), + } } /// Toggle clipping the alpha of a layer to the alpha of the layer below it in the layer stack diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index aec941840e5..a19b3ef9853 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, GradientRamp}; +use graphic_types::vector_types::vector::style::{Gradient, GradientRamp, MeshGradient, MeshGradientSurface}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::RenderMetadata; @@ -93,6 +93,8 @@ macro_rules! tagged_value { /// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.) #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), + /// Stored as the `MeshGradientSurface` exchange struct (nested `{ mesh: ... }`), materializing as an `Item` at runtime. + MeshGradient(MeshGradientSurface), Strokes(Vec), BrushCache(BrushCache), // ======================= @@ -137,6 +139,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => lengths.cache_hash(state), Self::BoxCorners(values) => values.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), + Self::MeshGradient(surface) => surface.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), Self::BrushCache(cache) => cache.cache_hash(state), // ======================= @@ -201,6 +204,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Box::new(Item::::from(ramp)), + Self::MeshGradient(surface) => Box::new(Item::::from(surface)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Box::new(list) @@ -268,6 +272,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), + Self::MeshGradient(surface) => Arc::new(Item::::from(surface)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Arc::new(list) @@ -301,6 +306,7 @@ macro_rules! tagged_value { Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), Self::GradientRamp(_) => item!(Gradient), + Self::MeshGradient(_) => item!(MeshGradient), Self::Strokes(_) => list!(Stroke), Self::BrushCache(_) => item!(BrushCache), // ======================= @@ -341,6 +347,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(*downcast::(input).unwrap()))), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(&*downcast::>(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(downcast::>(input).unwrap().into_iter().map(Item::into_element).collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushCache(downcast::>(input).unwrap().into_element())), // ======================= @@ -375,6 +383,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(input.downcast_ref::().unwrap().clone()))), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(input.downcast_ref::>().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(input.downcast_ref::>().unwrap().iter_element_values().cloned().collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushCache(input.downcast_ref::>().unwrap().element().clone())), // ======================= @@ -403,6 +413,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) } if name == std::any::type_name::() { return Some(TaggedValue::DashPattern(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BoxCorners(Vec::new())) } + if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradientSurface::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BrushCache(Default::default())) } @@ -461,6 +472,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"), Self::BoxCorners(values) => format!("BoxCorners({values:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), + Self::MeshGradient(surface) => format!("MeshGradient({surface:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), Self::BrushCache(cache) => format!("{cache:?}"), // ======================= diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index db58d1b779d..ecfe71fea53 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(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] + vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index f9df0dceb40..ff5639e6c56 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -8,7 +8,7 @@ use graphene_std::animation::RealTimeMode; use graphene_std::any::DynAnyNode; use graphene_std::brush::Stroke; 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; @@ -44,6 +44,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]), @@ -52,6 +53,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]), @@ -104,6 +106,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]), @@ -124,6 +127,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]), @@ -384,6 +388,7 @@ fn node_registry() -> HashMap HashMap, Color, Gradient, + MeshGradient, f32, f64, u32, @@ -461,6 +467,7 @@ fn node_registry() -> HashMap HashMap, element: Graphic)); @@ -575,6 +583,7 @@ fn node_registry() -> HashMap), attribute_value_node!(List), attribute_value_node!(List), + attribute_value_node!(List), attribute_value_node!(List), attribute_value_node!(List>), #[cfg(feature = "gpu")] @@ -602,6 +611,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/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs index ab8b0222393..dc5cdee8957 100644 --- a/node-graph/libraries/core-types/src/context.rs +++ b/node-graph/libraries/core-types/src/context.rs @@ -1,3 +1,4 @@ +use crate::paint::PaintRenderParams; use crate::transform::Footprint; use glam::DVec2; pub use no_std_types::context::{ArcCtx, Ctx}; @@ -51,6 +52,17 @@ pub trait CloneVarArgs: ExtractVarArgs { // fn box_clone(&self) -> Vec; fn arc_clone(&self) -> Option>; } +pub trait ExtractPaintRenderParams { + #[track_caller] + fn try_paint_render_params(&self) -> Option<&PaintRenderParams>; + #[track_caller] + fn paint_render_params(&self) -> &PaintRenderParams { + self.try_paint_render_params().unwrap_or_else(|| { + log::error!("Context did not have paint render params, called from: {}", Location::caller()); + &PaintRenderParams::DEFAULT + }) + } +} // ============= // INJECT TRAITS @@ -63,6 +75,7 @@ pub trait InjectAnimationTime {} pub trait InjectPointerPosition {} pub trait InjectPosition {} pub trait InjectIndex {} +pub trait InjectPaintRenderParams {} pub trait InjectVarArgs {} // ================ @@ -77,7 +90,8 @@ pub trait ExtractAll: ExtractPointerPosition + ExtractPosition + ExtractIndex + - ExtractVarArgs {} + ExtractVarArgs + + ExtractPaintRenderParams {} impl< T: ?Sized // Extract traits @@ -87,7 +101,8 @@ impl< + ExtractPointerPosition + ExtractPosition + ExtractIndex - + ExtractVarArgs, + + ExtractVarArgs + + ExtractPaintRenderParams, > ExtractAll for T { } @@ -103,6 +118,7 @@ impl InjectPointerPosition for T {} impl InjectPosition for T {} impl InjectIndex for T {} impl InjectVarArgs for T {} +impl InjectPaintRenderParams for T {} // ============= // MODIFY TRAITS @@ -116,6 +132,7 @@ pub trait ModifyPointerPosition: ExtractPointerPosition + InjectPointerPosition pub trait ModifyPosition: ExtractPosition + InjectPosition {} pub trait ModifyIndex: ExtractIndex + InjectIndex {} pub trait ModifyVarArgs: ExtractVarArgs + InjectVarArgs {} +pub trait ModifyPaintRenderParams: ExtractPaintRenderParams + InjectPaintRenderParams {} impl ModifyFootprint for T {} impl ModifyRealTime for T {} @@ -124,6 +141,7 @@ impl ModifyPointerPosit impl ModifyPosition for T {} impl ModifyIndex for T {} impl ModifyVarArgs for T {} +impl ModifyPaintRenderParams for T {} // ================ // CONTEXT FEATURES @@ -140,6 +158,7 @@ pub enum ContextFeature { ExtractPosition, ExtractIndex, ExtractVarArgs, + ExtractPaintRenderParams, InjectFootprint, InjectRealTime, InjectAnimationTime, @@ -147,6 +166,7 @@ pub enum ContextFeature { InjectPosition, InjectIndex, InjectVarArgs, + InjectPaintRenderParams, } // Internal bitflags for fast compiler analysis @@ -162,6 +182,7 @@ bitflags! { const POSITION = 1 << 4; const INDEX = 1 << 5; const VARARGS = 1 << 6; + const PAINT_RENDER_PARAMS = 1 << 7; } } @@ -181,6 +202,7 @@ impl ContextFeatures { ContextFeatures::POSITION => "Position", ContextFeatures::INDEX => "Index", ContextFeatures::VARARGS => "VarArgs", + ContextFeatures::PAINT_RENDER_PARAMS => "PaintRenderParams", _ => "Multiple Features", } } @@ -210,6 +232,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::ExtractPosition => ContextFeatures::POSITION, ContextFeature::ExtractIndex => ContextFeatures::INDEX, ContextFeature::ExtractVarArgs => ContextFeatures::VARARGS, + ContextFeature::ExtractPaintRenderParams => ContextFeatures::PAINT_RENDER_PARAMS, _ => ContextFeatures::empty(), }; inject |= match feature { @@ -220,6 +243,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::InjectPosition => ContextFeatures::POSITION, ContextFeature::InjectIndex => ContextFeatures::INDEX, ContextFeature::InjectVarArgs => ContextFeatures::VARARGS, + ContextFeature::InjectPaintRenderParams => ContextFeatures::PAINT_RENDER_PARAMS, _ => ContextFeatures::empty(), }; } @@ -292,6 +316,12 @@ impl CloneVarArgs for Option { } } +impl ExtractPaintRenderParams for Option { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + self.as_ref().and_then(|ctx| ctx.try_paint_render_params()) + } +} + // ================================ // EXTRACT TRAIT IMPLS FOR `Arc` // ================================ @@ -346,6 +376,12 @@ impl CloneVarArgs for Arc { } } +impl ExtractPaintRenderParams for Arc { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + (**self).try_paint_render_params() + } +} + // ============================ // EXTRACT TRAIT IMPLS FOR `&T` // ============================ @@ -496,6 +532,11 @@ impl ExtractVarArgs for OwnedContextImpl { }; } } +impl ExtractPaintRenderParams for OwnedContextImpl { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + self.paint_render_params.as_ref() + } +} impl CloneVarArgs for Arc { fn arc_clone(&self) -> Option> { @@ -521,6 +562,7 @@ pub struct OwnedContextImpl { position: Option>, // This could be converted into a single enum to save extra bytes index: Option>, + paint_render_params: Option, varargs: Option>, } @@ -534,6 +576,7 @@ impl std::fmt::Debug for OwnedContextImpl { .field("pointer_position", &self.pointer_position) .field("index", &self.index) .field("varargs_len", &self.varargs.as_ref().map(|x| x.len())) + .field("paint_render_params", &self.paint_render_params) .finish() } } @@ -554,6 +597,7 @@ impl graphene_hash::CacheHash for OwnedContextImpl { self.position.cache_hash(state); self.index.cache_hash(state); self.hash_varargs(state); + self.paint_render_params.cache_hash(state); } } @@ -578,6 +622,7 @@ impl OwnedContextImpl { let pointer_position = bitflags.contains(ContextFeatures::POINTER_POSITION).then(|| value.try_pointer_position()).flatten(); let position = bitflags.contains(ContextFeatures::POSITION).then(|| value.try_position()).flatten().map(|x| x.collect()); let index = bitflags.contains(ContextFeatures::INDEX).then(|| value.try_index()).flatten().map(|x| x.collect()); + let paint_render_params = bitflags.contains(ContextFeatures::PAINT_RENDER_PARAMS).then(|| value.try_paint_render_params().copied()).flatten(); OwnedContextImpl { parent, @@ -588,6 +633,7 @@ impl OwnedContextImpl { position, index, varargs: None, + paint_render_params, } } @@ -601,6 +647,7 @@ impl OwnedContextImpl { position: None, index: None, varargs: None, + paint_render_params: None, } } } @@ -671,6 +718,10 @@ impl OwnedContextImpl { self.varargs = Some(Arc::new([value])); self } + pub fn with_paint_render_params(mut self, paint_render_params: PaintRenderParams) -> Self { + self.paint_render_params = Some(paint_render_params); + self + } pub fn into_context(self) -> Option> { Some(Arc::new(self)) } diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 01b53ca24da..21b6ce8e2e0 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -10,6 +10,7 @@ pub mod memo; pub mod misc; pub mod none; pub mod ops; +pub mod paint; pub mod registry; pub mod render_complexity; pub mod transform; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 37acdad0a40..3600215f165 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -122,6 +122,8 @@ pub const ATTR_MAX_HEIGHT: &str = "max_height"; pub const ATTR_LETTER_TILT: &str = "letter_tilt"; /// Text item's `TextAlign` horizontal alignment of lines within the block. pub const ATTR_TEXT_ALIGN: &str = "text_align"; +/// Item's texture. (`Option>`) +pub const ATTR_TEXTURE: &str = "texture"; // ===================== // TYPE: NodeIdPath diff --git a/node-graph/libraries/core-types/src/paint.rs b/node-graph/libraries/core-types/src/paint.rs new file mode 100644 index 00000000000..cadf4bf1cc9 --- /dev/null +++ b/node-graph/libraries/core-types/src/paint.rs @@ -0,0 +1,10 @@ +use glam::DAffine2; + +#[derive(Clone, Copy, Debug, graphene_hash::CacheHash)] +pub struct PaintRenderParams { + pub fallback_paint_to_target: Option, +} + +impl PaintRenderParams { + pub const DEFAULT: Self = Self { fallback_paint_to_target: None }; +} diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index c6848c0dc12..6a99a5b1afb 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -13,7 +13,7 @@ use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use raster_types::{CPU, GPU, Raster}; pub use vector_types::Vector; -use vector_types::{Gradient, GradientSpread}; +use vector_types::{Gradient, GradientSpread, MeshGradient}; /// The possible forms of graphical content that can be rendered by the Render node (to targets like SVG and raster) or another render boundary node. #[derive(Clone, Debug, CacheHash, PartialEq, DynAny)] @@ -26,6 +26,7 @@ pub enum Graphic { RasterGPU(Item>), Color(Item), Gradient(Item), + MeshGradient(Box>), Text(Item), NoneList(List), GraphicList(List), @@ -34,6 +35,7 @@ pub enum Graphic { RasterGPUList(List>), ColorList(List), GradientList(List), + MeshGradientList(List), TextList(List), StrokeList(List), } @@ -142,6 +144,23 @@ impl From> for Graphic { } } +// MeshGradient +impl From for Graphic { + fn from(mesh_gradient: MeshGradient) -> Self { + Graphic::MeshGradient(Box::new(Item::new_from_element(mesh_gradient))) + } +} +impl From> for Graphic { + fn from(mesh_gradient: Item) -> Self { + Graphic::MeshGradient(Box::new(mesh_gradient)) + } +} +impl From> for Graphic { + fn from(mesh_gradient: List) -> Self { + Graphic::MeshGradientList(mesh_gradient) + } +} + // Stroke impl From for Graphic { fn from(stroke: Stroke) -> Self { @@ -317,12 +336,14 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterCPU(item) => bake_item_transform(item, transform), Graphic::RasterGPU(item) => bake_item_transform(item, transform), Graphic::Gradient(item) => bake_item_transform(item, transform), + Graphic::MeshGradient(item) => bake_item_transform(item, transform), Graphic::Text(item) => bake_item_transform(item, transform), Graphic::GraphicList(list) => bake_list_transform(list, transform), Graphic::VectorList(list) => bake_list_transform(list, transform), Graphic::RasterCPUList(list) => bake_list_transform(list, transform), Graphic::RasterGPUList(list) => bake_list_transform(list, transform), Graphic::GradientList(list) => bake_list_transform(list, transform), + Graphic::MeshGradientList(list) => bake_list_transform(list, transform), Graphic::TextList(list) => bake_list_transform(list, transform), Graphic::StrokeList(list) => bake_list_transform(list, transform), // A color has no spatial extent, so there is no placement for a transform to move @@ -452,6 +473,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::MeshGradientList(self)) + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path: NodeIdPath = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); @@ -565,6 +592,7 @@ impl Graphic { Graphic::RasterGPU(item) => item_clipped(item), Graphic::Color(item) => item_clipped(item), Graphic::Gradient(item) => item_clipped(item), + Graphic::MeshGradient(item) => item_clipped(item), Graphic::Text(item) => item_clipped(item), Graphic::NoneList(list) => all_clipped(list), Graphic::VectorList(list) => all_clipped(list), @@ -573,6 +601,7 @@ impl Graphic { Graphic::RasterGPUList(list) => all_clipped(list), Graphic::ColorList(list) => all_clipped(list), Graphic::GradientList(list) => all_clipped(list), + Graphic::MeshGradientList(list) => all_clipped(list), Graphic::TextList(list) => all_clipped(list), Graphic::StrokeList(list) => all_clipped(list), } @@ -627,6 +656,8 @@ impl Graphic { && list.element(index).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())) }) } + Graphic::MeshGradient(item) => item_opacity_is_full(item) && item.element().corners().all(|corner| corner.color.is_opaque()), + Graphic::MeshGradientList(list) => !list.is_empty() && every_item_has_full_opacity(list) && list.iter_element_values().all(|mesh| mesh.corners().all(|corner| corner.color.is_opaque())), Graphic::Text(_) | Graphic::TextList(_) => false, Graphic::StrokeList(_) => false, } @@ -654,6 +685,8 @@ impl Graphic { // A stopless ramp paints as solid black, matching `Gradient::evaluate`, so it counts as transparent only once it has stops Graphic::Gradient(item) => item_opacity_is_zero(item) || (!item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.)), Graphic::GradientList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)), + Graphic::MeshGradient(item) => item_opacity_is_zero(item) || item.element().corners().all(|corner| corner.color.a() == 0.), + Graphic::MeshGradientList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|mesh| mesh.corners().all(|corner| corner.color.a() == 0.)), // Their content is never inspected, so zeroed opacity is the only invisibility these can report Graphic::RasterCPU(item) => item_opacity_is_zero(item), Graphic::RasterGPU(item) => item_opacity_is_zero(item), @@ -675,11 +708,12 @@ impl Graphic { match self { // A leaf always holds exactly one element, so only the none-typed content is truly empty Graphic::None(_) | Graphic::NoneList(_) => true, - Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::Text(_) => false, + Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::MeshGradient(_) | Graphic::Text(_) => false, Graphic::GraphicList(list) => list.is_empty(), Graphic::VectorList(list) => list.is_empty(), Graphic::ColorList(list) => list.is_empty(), Graphic::GradientList(list) => list.is_empty(), + Graphic::MeshGradientList(list) => list.is_empty(), Graphic::RasterCPUList(list) => list.is_empty(), Graphic::RasterGPUList(list) => list.is_empty(), Graphic::TextList(list) => list.is_empty(), @@ -830,6 +864,7 @@ impl BoundingBox for Graphic { Graphic::RasterGPU(item) => item.bounding_box(transform, include_stroke), Graphic::Color(item) => item.bounding_box(transform, include_stroke), Graphic::Gradient(item) => item.bounding_box(transform, include_stroke), + Graphic::MeshGradient(item) => item.bounding_box(transform, include_stroke), Graphic::Text(item) => item.bounding_box(transform, include_stroke), Graphic::VectorList(list) => vector_list_bounding_box(list, transform, include_stroke), Graphic::RasterCPUList(list) => list.bounding_box(transform, include_stroke), @@ -837,6 +872,7 @@ impl BoundingBox for Graphic { Graphic::GraphicList(list) => list.bounding_box(transform, include_stroke), Graphic::ColorList(list) => list.bounding_box(transform, include_stroke), Graphic::GradientList(list) => list.bounding_box(transform, include_stroke), + Graphic::MeshGradientList(list) => list.bounding_box(transform, include_stroke), Graphic::TextList(list) => list.bounding_box(transform, include_stroke), Graphic::StrokeList(list) => list.bounding_box(transform, include_stroke), } @@ -851,6 +887,7 @@ impl BoundingBox for Graphic { Graphic::RasterGPU(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::MeshGradient(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::VectorList(vector) => vector_list_bounding_box(vector, transform, include_stroke), Graphic::RasterCPUList(raster) => raster.thumbnail_bounding_box(transform, include_stroke), @@ -858,6 +895,7 @@ impl BoundingBox for Graphic { Graphic::GraphicList(list) => list.thumbnail_bounding_box(transform, include_stroke), Graphic::ColorList(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::GradientList(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::MeshGradientList(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::TextList(list) => list.thumbnail_bounding_box(transform, include_stroke), Graphic::StrokeList(list) => list.thumbnail_bounding_box(transform, include_stroke), } @@ -874,13 +912,30 @@ impl RenderComplexity for Graphic { Self::RasterGPU(item) => item.render_complexity(), Self::Color(item) => item.render_complexity(), Self::Gradient(item) => item.render_complexity(), + Self::MeshGradient(item) => item.render_complexity(), Self::Text(item) => item.render_complexity(), Self::GraphicList(list) => list.render_complexity(), - Self::VectorList(list) => list.render_complexity(), + Self::VectorList(list) => { + let element_complexity = list.render_complexity(); + + // A mesh gradient paint costs far more to render than the geometry it covers, so an element's + // appearance counts toward its complexity — that is what keeps its thumbnail from being attempted. + let paint_complexity = list + .iter_attribute_values::(ATTR_APPEARANCE) + .into_iter() + .flatten() + .filter_map(|appearance| appearance.0.iter_attribute_values::(ATTR_PAINT)) + .flatten() + .map(|paint| paint.render_complexity()) + .fold(0, usize::saturating_add); + + element_complexity.saturating_add(paint_complexity) + } Self::RasterCPUList(list) => list.render_complexity(), Self::RasterGPUList(list) => list.render_complexity(), Self::ColorList(list) => list.render_complexity(), Self::GradientList(list) => list.render_complexity(), + Self::MeshGradientList(list) => list.render_complexity(), Self::TextList(list) => list.render_complexity(), Self::StrokeList(list) => list.render_complexity(), } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 7da33a4eb05..6fab8037a20 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -32,6 +32,7 @@ vello = { workspace = true } vello_encoding = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } +image = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index c0207ff2796..857273aef8b 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -287,12 +287,14 @@ impl RenderExt for Graphic { | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) + | Graphic::MeshGradient(_) | Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) | Graphic::GradientList(_) | Graphic::TextList(_) + | Graphic::MeshGradientList(_) | Graphic::StrokeList(_) => { 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. @@ -313,7 +315,7 @@ impl RenderExt for Graphic { } /// Emits an SVG `` paint server into `svg_defs` that renders the given graphic 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 for mesh gradients, not considering tiling yet. fn render_svg_pattern(svg_defs: &mut String, paint: &Graphic, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { let min = bounds.transform_point2(DVec2::ZERO); let max = bounds.transform_point2(DVec2::ONE); @@ -322,14 +324,16 @@ fn render_svg_pattern(svg_defs: &mut String, paint: &Graphic, stroke_transform: return None; } + let pattern_transform = stroke_transform * DAffine2::from_translation(min); + // Render the pattern content recursively let mut content = SvgRender::new(); + content.transform = pattern_transform; paint.render_svg(&mut content, &render_params.for_pattern()); // Unwrap the inner def element write!(svg_defs, "{}", content.svg_defs).unwrap(); - let pattern_transform = stroke_transform * DAffine2::from_translation(min); let transform_str = format_transform_matrix(pattern_transform); let transform_attr = if transform_str.is_empty() { String::new() diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 932bf144484..8d19e3c7019 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,13 +1,15 @@ +mod mesh_gradient; + use crate::render_ext::{PaintTarget, RenderExt}; +use crate::renderer::mesh_gradient::SvgMeshPatchRenderer; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; -use core_types::CacheHash; use core_types::blending::{BlendMode, apply_blend_mode}; use core_types::bounds::BoundingBox; use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; use core_types::consts::DEFAULT_FONT_SIZE; -use core_types::list::ATTR_APPEARANCE; +use core_types::list::{ATTR_APPEARANCE, ATTR_TEXTURE}; use core_types::list::{Item, List, NodeIdPath}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; @@ -15,9 +17,10 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, - ATTR_TRANSFORM, + ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPACE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, + ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; +use core_types::{ATTR_GRADIENT_INTERPOLATION, CacheHash}; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; @@ -28,7 +31,7 @@ use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::misc::dvec2_to_point; use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Appearance, Artboard, Cover, Coverage, FillAndStroke, Graphic, Vector}; -use kurbo::{Affine, BezPath, Cap, Join, PathEl, Shape, StrokeOpts}; +use kurbo::{Affine, BezPath, Cap, Join, ParamCurve, PathEl, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; @@ -39,7 +42,8 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GradientSettings, GradientSpread}; +use vector_types::GradientInterpolation; +use vector_types::gradient::{GradientSettings, GradientSpace, GradientSpread, MeshGradient}; use vello::*; /// A borrowed view of one item of ranked content: one index of a `List`'s attributes, or a lone `Item` reading its own envelope. @@ -1058,6 +1062,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(item) => render_color_item_svg(ItemRef::Item(item), render, render_params), Graphic::Gradient(item) => render_gradient_item_svg(ItemRef::Item(item), render, render_params), + Graphic::MeshGradient(item) => render_mesh_gradient_item_svg(ItemRef::Item(item), render, render_params), Graphic::Text(item) => render_text_item_svg(ItemRef::Item(item), render, render_params), Graphic::GraphicList(list) => list.render_svg(render, render_params), Graphic::VectorList(list) => list.render_svg(render, render_params), @@ -1065,6 +1070,7 @@ impl Render for Graphic { Graphic::RasterGPUList(_) => (), Graphic::ColorList(list) => list.render_svg(render, render_params), Graphic::GradientList(list) => list.render_svg(render, render_params), + Graphic::MeshGradientList(list) => list.render_svg(render, render_params), Graphic::TextList(list) => list.render_svg(render, render_params), Graphic::StrokeList(_) => (), } @@ -1086,6 +1092,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => render_raster_gpu_item_to_vello(ItemRef::Item(item), scene, transform, context, render_params), Graphic::Color(item) => render_color_item_to_vello(ItemRef::Item(item), scene, render_params), Graphic::Gradient(item) => render_gradient_item_to_vello(ItemRef::Item(item), scene, transform, render_params), + Graphic::MeshGradient(item) => render_mesh_gradient_item_to_vello(ItemRef::Item(item), scene, transform, context, render_params), Graphic::Text(item) => render_text_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::GraphicList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::VectorList(list) => list.render_to_vello(scene, transform, context, render_params), @@ -1093,6 +1100,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::ColorList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::GradientList(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::MeshGradientList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::TextList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::StrokeList(_) => (), } @@ -1129,6 +1137,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Color(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Gradient(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::MeshGradient(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Text(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::RasterCPUList(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -1162,6 +1171,14 @@ impl Render for Graphic { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } + Graphic::MeshGradientList(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::TextList(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -1189,6 +1206,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => collect_raster_metadata(Some(ItemRef::Item(item)), metadata, footprint, element_id), Graphic::Color(_) => (), Graphic::Gradient(item) => collect_gradient_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, element_id), + Graphic::MeshGradient(item) => collect_mesh_gradient_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, element_id), Graphic::Text(item) => collect_text_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, footprint, element_id), Graphic::GraphicList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::VectorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), @@ -1196,6 +1214,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::ColorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::GradientList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), + Graphic::MeshGradientList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::TextList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::StrokeList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), } @@ -1210,6 +1229,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), click_targets), Graphic::Color(_) => (), Graphic::Gradient(item) => add_gradient_item_click_targets(ItemRef::Item(item), click_targets), + Graphic::MeshGradient(item) => add_mesh_gradient_item_click_targets(ItemRef::Item(item), click_targets), Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), click_targets), Graphic::GraphicList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), @@ -1217,6 +1237,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::ColorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::GradientList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), + Graphic::MeshGradientList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::TextList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::StrokeList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), } @@ -1231,6 +1252,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), outlines), Graphic::Color(_) => (), Graphic::Gradient(item) => add_gradient_item_outline_targets(ItemRef::Item(item), outlines), + Graphic::MeshGradient(item) => add_mesh_gradient_item_outline_targets(ItemRef::Item(item), outlines), Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), outlines), Graphic::GraphicList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), @@ -1238,6 +1260,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::ColorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::GradientList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), + Graphic::MeshGradientList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::TextList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::StrokeList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), } @@ -1279,6 +1302,7 @@ impl Render for List { for index in 0..self.len() { let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; let (location, dimensions, background, clip) = read_artboard_attributes(self, index); + let artboard_transform = DAffine2::from_translation(location); let x = location.x.min(location.x + dimensions.x); let y = location.y.min(location.y + dimensions.y); @@ -1303,7 +1327,7 @@ impl Render for List { "g", // Group tag attributes |attributes| { - let matrix = format_transform_matrix(DAffine2::from_translation(location)); + let matrix = format_transform_matrix(artboard_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); } @@ -1850,6 +1874,8 @@ fn render_vector_item_to_vello( | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) + | Graphic::MeshGradient(_) + | Graphic::MeshGradientList(_) | Graphic::StrokeList(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, paint_render_params); @@ -1943,6 +1969,8 @@ fn render_vector_item_to_vello( | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) + | Graphic::MeshGradient(_) + | Graphic::MeshGradientList(_) | Graphic::StrokeList(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); @@ -2681,6 +2709,22 @@ fn gradient_control_outline(gradient_form: GradientForm) -> BezPath { } } +/// The mesh's painted region as a click/outline target. +fn mesh_control_target(mesh: &MeshGradient) -> ClickTarget { + let mut boundary = BezPath::new(); + + for patch in mesh.patches().flatten() { + let [top, bottom, left, right] = patch.edges; + boundary.move_to(top.start()); + for edge in [top, right, bottom.reverse(), left.reverse()] { + boundary.push(edge.as_path_el()); + } + boundary.close_path(); + } + + ClickTarget::new_with_path(boundary, 0.) +} + /// Whether the control geometry's interior is a draggable click area: a radial's main ellipse acts as the layer's handle regardless of spread, while a linear's control line has no interior. fn gradient_control_interior_is_clickable(gradient_form: GradientForm) -> bool { gradient_form == GradientForm::Radial @@ -2953,6 +2997,151 @@ fn add_gradient_item_outline_targets(item: ItemRef<'_, Gradient>, outlines: &mut outlines.push(target); } +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..self.len() { + render_mesh_gradient_item_svg(ItemRef::ListItem(self, index), render, render_params); + } + } + + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + for index in 0..self.len() { + render_mesh_gradient_item_to_vello(ItemRef::ListItem(self, index), scene, parent_transform, context, render_params); + } + } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option, _inherited_appearance: Option<&Appearance>) { + collect_mesh_gradient_items_metadata((0..self.len()).map(|index| ItemRef::ListItem(self, index)), metadata, element_id); + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { + for index in 0..self.len() { + add_mesh_gradient_item_click_targets(ItemRef::ListItem(self, index), click_targets); + } + } + + fn add_upstream_outline_targets(&self, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { + self.add_upstream_click_targets(outlines, inherited_appearance); + } +} + +/// Emits one item of mesh gradient content as SVG. +fn render_mesh_gradient_item_svg(item: ItemRef<'_, MeshGradient>, render: &mut SvgRender, render_params: &RenderParams) { + // SVG mesh gradient rendering has two stages: + // + // 1. Approximate the patch's color field over a unit square. + // N u-direction gradients using N-1 v-direction masks to approximate the color surface. + // The key observation is that source-over compositing with opaque color layers forms a convex combination. + // This allows us to reproduce a bicubic Bezier surface or approximate any surface, by stacking gradients and alpha masks. + // + // 2. Warp the unit square into the Coons patch geometry using an feDisplacementMap. + // feDisplacementMap performs inverse mapping: for each output position (x, y), it samples the source at + // P'(x, y) = P(x + scale * (XC(x, y) - 0.5), y + scale * (YC(x, y) - 0.5)). + // We numerically invert the Coons patch to find the source UV corresponding to each output position, + // then encode the offset from the output position to that UV in the displacement map's X and Y channels. + // Therefore, any injective Coons patch can be approximated by a raster displacement map, with the result clipped to the patch boundary. + + let Some(mesh_gradient) = item.element() else { return }; + let space: GradientSpace = item.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE); + let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); + let Some(mesh_evaluator) = mesh_gradient.evaluator(space, interpolation_method).ok() else { + return; + }; + + let mesh_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let parent_transform = DAffine2::from_scale(DVec2::splat(1. / render_params.scale)) * render_params.footprint.transform * render.transform; + + let blend_mode: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let opacity = opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }; + + let has_transparency = mesh_gradient.corners().any(|corner| !corner.color.is_opaque()); + let mesh_transparency_mask_id = has_transparency.then(|| format!("mg-ma-{}", generate_uuid())); + let mut mesh_transparency_field = String::new(); + + let mut patch_renderer = SvgMeshPatchRenderer::new(render, &mesh_evaluator, parent_transform, mesh_transform, has_transparency.then_some(&mut mesh_transparency_field)); + + render.parent_tag( + "g", + |attributes| { + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + if let Some(mask_id) = &mesh_transparency_mask_id.as_deref() { + attributes.push("mask", format!("url(#{mask_id})")); + } + }, + |render| { + for patch in mesh_gradient.patches() { + let Some(patch) = patch else { continue }; + patch_renderer.render_patch(render, &patch); + } + }, + ); + if let Some(mask_id) = mesh_transparency_mask_id.as_deref() { + write!( + &mut render.svg_defs, + r##"{mesh_transparency_field}"##, + ) + .unwrap(); + } +} + +/// Draws one item of mesh gradient content into the Vello scene. +fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + let texture_item: Option> = item.attribute_cloned_or_default(ATTR_TEXTURE); + let Some(texture_item) = texture_item else { return }; + let texture_transform: DAffine2 = texture_item.attribute_cloned_or_default(ATTR_TRANSFORM); + let texture = texture_item.into_element(); + + let raster_item_ref = ItemRef::Item(&Item::from(Raster::::new_gpu(texture))); + render_raster_gpu_item_to_vello(raster_item_ref, scene, parent_transform * texture_transform, context, render_params); +} + +fn collect_mesh_gradient_items_metadata<'a>(items: impl Iterator>, metadata: &mut RenderMetadata, element_id: Option) { + let Some(element_id) = element_id else { return }; + + let mut item_zero_inverse = None; + let mut targets = Vec::new(); + for item in items { + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + // The first item's transform is the reference all targets bake against, matching the `local_transforms` entry `Graphic::collect_metadata` records + let item_zero_inverse = *item_zero_inverse.get_or_insert_with(|| if transform_is_invertible(item_transform) { item_transform.inverse() } else { DAffine2::IDENTITY }); + + let Some(mesh_gradient) = item.element() else { continue }; + + let mut target = mesh_control_target(mesh_gradient); + target.apply_transform(item_zero_inverse * item_transform); + targets.push(Arc::new(target)); + } + + if targets.is_empty() { + return; + } + metadata.outlines.insert(element_id, targets.clone()); + // The painted region is the mesh boundary itself, so its interior is what a click lands on + metadata.click_targets.insert(element_id, targets); +} + +fn add_mesh_gradient_item_click_targets(item: ItemRef<'_, MeshGradient>, click_targets: &mut Vec) { + let Some(mesh_gradient) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + let mut target = mesh_control_target(mesh_gradient); + target.apply_transform(transform); + click_targets.push(target); +} + +/// Collects one gradient item's control geometry as an outline target. +fn add_mesh_gradient_item_outline_targets(item: ItemRef<'_, MeshGradient>, outlines: &mut Vec) { + add_mesh_gradient_item_click_targets(item, outlines) +} + /// 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/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs new file mode 100644 index 00000000000..1dc5731fcf6 --- /dev/null +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -0,0 +1,885 @@ +use std::collections::VecDeque; +use std::fmt::Write; +use std::ops::{Add, Mul, Sub}; + +use crate::{SvgRender, format_transform_matrix}; +use base64::Engine; +use core_types::uuid::generate_uuid; +use core_types::{Color, color::SRGBA8}; +use glam::{DAffine2, DVec2, Vec4}; +use image::ImageEncoder; +use kurbo::{Affine, BezPath, Shape}; +use vector_types::GradientInterpolation; +use vector_types::gradient::MeshPatch; +use vector_types::{ + gradient::GradientSpace, + mesh_gradient::{MeshGradientEvaluator, MeshPatchEvaluator}, +}; + +/// Patch padding for hiding anti-aliasing gaps, as a fraction of the patch's local height. +pub(super) const PATCH_INFLATION_FRACTION: f64 = 0.005; +/// Width and height of each generated displacement map. +const DISPLACEMENT_MAP_SIZE: usize = 128; +/// Fraction of the displacement map reserved as margin on each side to absorb floating-point error. +const DISPLACEMENT_MAP_MARGIN_PERCENTAGE: f64 = 0.03; +/// Exterior texels evaluated around the patch to cover displacement-map filtering. +const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 3; + +// =================== +// Color approximation +// =================== + +/// Returns adaptively sampled points that approximate a function with linear segments. +fn linear_approximation_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> +where + T: Copy + Add + Sub + Mul, +{ + // Maximum error allowed between a function and its linear approximation. + const ERROR_TOLERANCE: f32 = 2. / 255.; + // Relative positions sampled within each candidate interval. + const SAMPLES: [f32; 3] = [0.25, 0.5, 0.75]; + // Maximum depth of adaptive interval subdivision. + const MAX_DEPTH: usize = 8; + + let start_result = func(start); + let end_result = func(end); + let needs_split = SAMPLES.iter().any(|&sample| { + let t = start + (end - start) * sample; + error(start_result + (end_result - start_result) * sample, func(t)) > ERROR_TOLERANCE + }); + + if needs_split && depth < MAX_DEPTH { + let mid = (start + end) / 2.; + let mut points = linear_approximation_points(func, error, start, mid, depth + 1); + points.extend(linear_approximation_points(func, error, mid, end, depth + 1).into_iter().skip(1)); + points + } else { + vec![(start, start_result), (end, end_result)] + } +} + +/// Returns a source-over-adjusted Bernstein weight for the indexed mask layer. +pub(super) fn evaluate_source_over_bezier_alpha(index: usize, time: f32) -> f32 { + match index { + 0 => (1. - time).powi(3), + 1 => 3. * (1. - time).powi(2) / (time.powi(2) - 3. * time + 3.), + 2 => 3. * (1. - time) / (3. - 2. * time), + _ => unreachable!(), + } +} + +/// Quantizes gamma-encoded floating-point color channels into sRGBA8. +fn gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { + 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]), + } +} + +/// Maximum allowed error between the stacked SVG color layers and the true color surface, per channel. +const SVG_LAYER_ERROR_TOLERANCE: f32 = 2. / 255.; +/// Maximum number of bisections used when placing the v-direction layer rows. +const SVG_LAYER_MAX_DEPTH: usize = 8; +/// Maximum layers stacked per patch, bounding what a mesh the tolerance cannot reach is allowed to emit. +const SVG_LAYER_MAX_COUNT: usize = 64; + +/// The v-direction weights that blend the stacked SVG color layers. +#[derive(Clone, Debug)] +pub(super) enum SvgMeshVLayers { + /// Uses top-left color for the entire patch, no blend required. + Stepped, + /// The four Bezier control rows blended by the Bernstein basis, reproducing the bicubic surface. + BicubicBernstein, + /// Surface rows sampled at the given v values, blended linearly between adjacent rows. + LinearRows(Vec), +} + +impl SvgMeshVLayers { + /// Chooses the appropriate layer scheme for the chosen color space and interpolation method. + pub(super) fn new(evaluator: &MeshGradientEvaluator) -> Self { + match (evaluator.interpolation_method(), evaluator.space()) { + // A smooth gamma-sRGB surface can be reproduced from its four Bezier control rows using Bernstein source-over weights. + (GradientInterpolation::Smooth, GradientSpace::RgbGamma) => Self::BicubicBernstein, + // A bilinear gamma-sRGB surface is exactly two horizontal linear rows blended by one linear vertical mask. + (GradientInterpolation::Linear, GradientSpace::RgbGamma) => Self::LinearRows(vec![0., 1.]), + // Conversion from the interpolation color space to gamma sRGB makes the rendered surface nonlinear, so approximate it with adaptive rows. + (GradientInterpolation::Smooth | GradientInterpolation::Linear, _) => Self::LinearRows(Self::adaptive_row_knots(evaluator)), + (GradientInterpolation::Stepped, _) => Self::Stepped, + } + } + + /// Adaptively places v-direction row knots until linear blending approximates the color surface within tolerance. + fn adaptive_row_knots(evaluator: &MeshGradientEvaluator) -> Vec { + // Vec of (start, end, error) + let mut intervals = vec![(0_f32, 1_f32, linear_row_interval_error(evaluator, 0., 1.))]; + let smallest_interval = 1. / (1_u32 << SVG_LAYER_MAX_DEPTH) as f32; + // Refine the interval with the largest error first. + // Only failing intervals split, so the result matches an exhaustive subdivision unless the budget runs out. + // One row set shared by every patch keeps the mask gradients mesh-wide. + while intervals.len() < SVG_LAYER_MAX_COUNT - 1 { + let worst_interval_index = intervals + .iter() + .enumerate() + .filter(|&(_, &(start, end, error))| error > SVG_LAYER_ERROR_TOLERANCE && end - start > smallest_interval) + .max_by(|(_, first), (_, second)| first.2.total_cmp(&second.2)) + .map(|(index, _)| index); + let Some(worst_interval_index) = worst_interval_index else { break }; + + let (start, end, _) = intervals.swap_remove(worst_interval_index); + let middle = (start + end) / 2.; + intervals.push((start, middle, linear_row_interval_error(evaluator, start, middle))); + intervals.push((middle, end, linear_row_interval_error(evaluator, middle, end))); + } + intervals.sort_by(|first, second| first.0.total_cmp(&second.0)); + std::iter::once(0.).chain(intervals.iter().map(|&(_, end, _)| end)).collect() + } + + pub(super) fn layer_count(&self) -> usize { + match self { + Self::Stepped => 1, + Self::BicubicBernstein => 4, + Self::LinearRows(knots) => knots.len(), + } + } + + /// Returns the alpha the indexed layer needs for source-over compositing to reproduce its weight. + pub(super) fn source_over_alpha(&self, index: usize, v: f32) -> f32 { + match self { + Self::Stepped => 0., + Self::BicubicBernstein => evaluate_source_over_bezier_alpha(index, v), + // Layers are painted bottom-up, so everything below `index` is already covered wherever this layer is opaque. + // One clamped ramp per layer therefore composites into a linear blend of the two nearest rows. + Self::LinearRows(knots) => ((knots[index + 1] - v) / (knots[index + 1] - knots[index])).clamp(0., 1.), + } + } + + /// The v range the indexed layer's weight ramps across, or `None` when that weight is not a plain clamped ramp. + pub(super) fn source_over_ramp(&self, index: usize) -> Option<[f32; 2]> { + match self { + Self::Stepped => None, + Self::BicubicBernstein => None, + Self::LinearRows(knots) => Some([knots[index], knots[index + 1]]), + } + } + + /// Returns the u-direction color curve painted by the indexed layer. + pub(super) fn evaluate_layer_u_color(&self, patch_evaluator: MeshPatchEvaluator, color_bezier_row: usize, u: f32) -> Vec4 { + match self { + Self::Stepped => Vec4::from_array(patch_evaluator.evaluate_color(0., 0.)), + Self::BicubicBernstein => patch_evaluator + .evaluate_color_bezier_row(color_bezier_row, u) + .expect("Bicubic Bernstein layers should have the control points"), + Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[color_bezier_row])), + } + } +} + +/// Returns the largest per-channel error of linearly blending the exact surface rows at an interval's ends. +fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: f32) -> f32 { + // The rows are reproduced exactly, so error is sampled across u and between the rows in v. + const U_SAMPLES: usize = 64; + const V_SAMPLES: usize = 8; + + let mut worst_error = 0_f32; + for patch in evaluator.patches() { + for u_step in 0..=U_SAMPLES { + let u = u_step as f32 / U_SAMPLES as f32; + let start_color = Vec4::from_array(patch.evaluate_color(u, start)); + let end_color = Vec4::from_array(patch.evaluate_color(u, end)); + for v_step in 1..V_SAMPLES { + let sample = v_step as f32 / V_SAMPLES as f32; + let expected = Vec4::from_array(patch.evaluate_color(u, start + (end - start) * sample)); + let approximated = start_color + (end_color - start_color) * sample; + worst_error = worst_error.max((expected - approximated).abs().max_element()); + } + } + } + + worst_error +} + +// ============ +// SVG renderer +// ============ + +pub(super) struct SvgMeshPatchRenderer<'mesh, 'field> { + mesh_evaluator: &'mesh MeshGradientEvaluator, + v_layers: SvgMeshVLayers, + alpha_mask_gradient_ids: Vec, + parent_transform: DAffine2, + mesh_transform: DAffine2, + mesh_transparency_field: Option<&'field mut String>, +} + +impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { + pub(super) fn new( + render: &mut SvgRender, + mesh_evaluator: &'mesh MeshGradientEvaluator, + parent_transform: DAffine2, + mesh_transform: DAffine2, + mesh_transparency_field: Option<&'field mut String>, + ) -> Self { + // The layer stack is what carries the color space: gamma sRGB uses the bicubic Bernstein stack, + // while a nonlinear space stacks approximated rows so the compositor's linear blend still lands on the true surface. + let v_layers = SvgMeshVLayers::new(mesh_evaluator); + + // The v-direction mask to simulate 2D interpolation + let alpha_mask_gradient_ids = Self::render_alpha_mask_gradient(render, &v_layers); + + Self { + mesh_evaluator, + v_layers, + alpha_mask_gradient_ids, + parent_transform, + mesh_transform, + mesh_transparency_field, + } + } + + /// Define N-1 alpha functions from the v-direction layer weights and write them as approximated linear gradients, then return the ids. + /// They compensate for attenuation accumulated through source-over compositing, + /// making the final weights of the N color layers equal the layer scheme's weights. + /// The v-direction masks encode only those weights with no patch specific color data, so they can be shared by all patches. + fn render_alpha_mask_gradient(render: &mut SvgRender, v_layers: &SvgMeshVLayers) -> Vec { + let alpha_mask_gradient_group_id = generate_uuid(); + (0..v_layers.layer_count() - 1) + .map(|i| { + let id = format!("mg-ag{i}-{alpha_mask_gradient_group_id}"); + match v_layers.source_over_ramp(i) { + // Linear interpolation mask to blend i-th and (i+1)-th u direction gradients + Some([start, end]) => write!( + &mut render.svg_defs, + r##"{}"##, + clamped_ramp_gradient_stops_string(), + ), + // 4 Bernstein base functions for the v direction + None => write!( + &mut render.svg_defs, + r##"{}"##, + alpha_curve_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), + ), + } + .unwrap(); + + id + }) + .collect::>() + } + + fn render_alpha_mask(&self, render: &mut SvgRender, patch_unique_id: u64, map_region: [f64; 4]) -> Vec { + let [map_x, map_y, map_width, map_height] = map_region; + self.alpha_mask_gradient_ids + .iter() + .enumerate() + .map(|(i, gradient_id)| { + let mask_id = format!("mg-am{i}-{patch_unique_id}"); + write!( + &mut render.svg_defs, + r##" + + "##, + ) + .unwrap(); + mask_id + }) + .collect::>() + } + + pub(super) fn render_patch(&mut self, render: &mut SvgRender, patch: &MeshPatch) { + let unique_id = generate_uuid(); + let Some(patch_evaluator) = self.mesh_evaluator.patch(patch.index) else { return }; + + // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask + let mut patch_boundary_path = patch.boundary_path(); + let bounds = patch_boundary_path.bounding_box(); + let bounds_min = DVec2::new(bounds.x0, bounds.y0); + let bounds_max = DVec2::new(bounds.x1, bounds.y1); + let bounds_size = bounds_max - bounds_min; + if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { + return; + } + // Encode the deformation in a local patch-bounding-box space so patch translation and scaling do not consume PNG channel precision. + // That space has to reach the output through a uniform scale, since Firefox as of version 154 has a bug + // that converts `feDisplacementMap`'s `scale` into one isotropic filter-space length instead of one length per axis, + // so a local space that reaches the output non-uniformly displaces both axes by the wrong amount there. + let mesh_to_output = (self.parent_transform * self.mesh_transform).matrix2; + let output_scales = DVec2::new(mesh_to_output.x_axis.length(), mesh_to_output.y_axis.length()); + if !output_scales.is_finite() || output_scales.min_element() <= f64::EPSILON { + return; + } + let local_axes = DVec2::new(bounds_size.y * output_scales.y / output_scales.x, bounds_size.y); + let patch_extent = bounds_size / local_axes; + let local_to_patch_bbox = DAffine2::from_cols(DVec2::new(local_axes.x, 0.), DVec2::new(0., local_axes.y), bounds_min); + + let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &local_to_patch_bbox, patch_extent, &patch_boundary_path); + let [map_x, map_y, map_width, map_height] = region; + // feDisplacementMap decodes each channel as scale * (channel - 0.5). + // Twice the largest absolute component is therefore the smallest scale that covers every displacement and maximizes quantization precision. + let max_displacement = displacements.iter().map(|displacement| displacement.abs().max_element()).fold(0_f64, f64::max); + // Keep the scale nonzero when all displacements are zero. + let scale = (max_displacement * 2.).max(f64::EPSILON); + + let Some(displacement_map_png) = displacements_to_map_png(&displacements, scale) else { return }; + let preamble = "data:image/png;base64,"; + let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); + displacement_map_data_url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); + + let v_alpha_mask_ids = self.render_alpha_mask(render, unique_id, region); + + let extent_x = patch_extent.x; + let u_color_curves_gradient_ids = (0..self.v_layers.layer_count()) + .map(|i| { + let u_color_curve = |u| self.v_layers.evaluate_layer_u_color(patch_evaluator, i, u); + let stops = u_color_curve_to_gradient_stops_string(&u_color_curve); + let id = format!("mg-cg{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }) + .collect::>(); + + write!( + &mut render.svg_defs, + r##" + + + "## + ) + .unwrap(); + + // Add a centered stroke to expand the patch along its boundary normal and hide antialiasing gaps between patches. + let patch_clip_stroke_width = 2. * PATCH_INFLATION_FRACTION; + patch_boundary_path.apply_affine(Affine::new(local_to_patch_bbox.inverse().to_cols_array())); + let patch_boundary_d = patch_boundary_path.to_svg(); + + write!( + &mut render.svg_defs, + r##" + + "## + ) + .unwrap(); + + let patch_transform_str = format_transform_matrix(self.mesh_transform * local_to_patch_bbox); + render.parent_tag( + "g", + |attributes| { + attributes.push("transform", patch_transform_str.clone()); + }, + |render| { + render.parent_tag( + "g", + |attributes| { + attributes.push("mask", format!("url(#mc{unique_id})")); + }, + |render| { + render.parent_tag( + "g", + |attributes| { + attributes.push("style", "isolation:isolate"); + attributes.push("filter", format!("url(#fd{unique_id})")); + }, + |render| { + u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { + render.leaf_tag("rect", |attributes| { + attributes.push("x", map_x.to_string()); + attributes.push("y", map_y.to_string()); + attributes.push("width", map_width.to_string()); + attributes.push("height", map_height.to_string()); + attributes.push("fill", format!("url(#{gradient_id})")); + if let Some(mask_id) = v_alpha_mask_ids.get(i) { + attributes.push("mask", format!("url(#{mask_id})")); + } + }); + }); + }, + ); + }, + ); + }, + ); + + self.collect_transparency_field(render, patch, unique_id, patch_transform_str, patch_extent, &v_alpha_mask_ids); + } + + fn collect_transparency_field(&mut self, render: &mut SvgRender, patch: &MeshPatch, patch_unique_id: u64, patch_transform: String, patch_extent: DVec2, v_alpha_mask_ids: &[String]) -> Option<()> { + let mesh_transparency_field = self.mesh_transparency_field.as_deref_mut()?; + let patch_evaluator = self.mesh_evaluator.patch(patch.index)?; + let (map_min, map_size) = displacement_map_region(patch_extent); + let (map_x, map_y, map_width, map_height) = (map_min.x, map_min.y, map_size.x, map_size.y); + let extent_x = patch_extent.x; + + // Keep transparency as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. + let u_transparency_curves_gradient_ids: Vec = (0..self.v_layers.layer_count()) + .map(|i| { + // Only takes alpha value + let u_alpha_curve = |t| self.v_layers.evaluate_layer_u_color(patch_evaluator, i, t).w; + let stops = u_alpha_curve_to_gradient_stops_string(&u_alpha_curve); + let id = format!("mg-cag{i}-{patch_unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }) + .collect(); + + let mut patch_transparency_field = String::new(); + for (i, gradient_id) in u_transparency_curves_gradient_ids.iter().enumerate().rev() { + let mask = match v_alpha_mask_ids.get(i) { + Some(mask_id) => format!(r##" mask="url(#{mask_id})""##), + None => String::new(), + }; + write!( + patch_transparency_field, + r##""##, + ) + .unwrap(); + } + + write!( + mesh_transparency_field, + r##"{patch_transparency_field}"##, + ) + .unwrap(); + + Some(()) + } +} + +// ============================== +// SVG displacement map generator +// ============================== + +pub(super) struct DisplacementMapSamples { + /// Displacement-map region in local patch coordinates, including its margin. [x, y, width, height] + pub region: [f64; 4], + /// Row-major target-to-source displacement samples over `region`. + pub displacements: Vec, +} + +/// Returns the displacement-map region covering `patch_extent` plus the margin reserved on each side, as `(min, size)`. +fn displacement_map_region(patch_extent: DVec2) -> (DVec2, DVec2) { + let margin = DISPLACEMENT_MAP_MARGIN_PERCENTAGE / (1. - 2. * DISPLACEMENT_MAP_MARGIN_PERCENTAGE); + (-margin * patch_extent, (1. + 2. * margin) * patch_extent) +} + +/// Returns target-to-source displacement samples mapping local patch-bounding-box positions to source UVs. +/// `patch_extent` is the patch bounding box measured in the local space, so both the sampled region and the source UVs +/// span it rather than a unit square. +pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: MeshPatchEvaluator, local_to_patch_bbox: &DAffine2, patch_extent: DVec2, boundary: &BezPath) -> DisplacementMapSamples { + let size = DISPLACEMENT_MAP_SIZE; + let (map_min, map_size) = displacement_map_region(patch_extent); + let target_positions = |index: usize| { + let x = index % size; + let y = index / size; + let image_uv = DVec2::new((x as f64 + 0.5) / size as f64, (y as f64 + 0.5) / size as f64); + let local_position = map_min + image_uv * map_size; + (local_position, local_to_patch_bbox.transform_point2(local_position)) + }; + + // 81 samples of (uv, position) tuples in the patch + let inverse_seeds = { + // Number of initial intervals sampled along each patch axis + const INITIAL_SUBDIVISIONS: usize = 8; + let seed_count = (INITIAL_SUBDIVISIONS + 1).pow(2); + let mut seeds = Vec::with_capacity(seed_count); + for row in 0..=INITIAL_SUBDIVISIONS { + let v = row as f64 / INITIAL_SUBDIVISIONS as f64; + + for column in 0..=INITIAL_SUBDIVISIONS { + let u = column as f64 / INITIAL_SUBDIVISIONS as f64; + let uv = DVec2::new(u, v); + seeds.push((uv, patch_evaluator.evaluate_position(u, v))); + } + } + seeds + }; + let initial_uv_from_seeds = |target_position| { + inverse_seeds + .iter() + .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_position).total_cmp(&second_position.distance_squared(target_position))) + .map(|(uv, _)| *uv) + .unwrap_or(DVec2::splat(0.5)) + }; + + let inside_patch = (0..size * size) + .map(|index| { + let (_, target_position_in_mesh) = target_positions(index); + boundary.contains(kurbo::Point::new(target_position_in_mesh.x, target_position_in_mesh.y)) + }) + .collect::>(); + + let buffer = DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS as isize; + // The target region on the displacement map that requires source position + let sampled_region = (0..size * size) + .map(|index| { + let x = (index % size) as isize; + let y = (index / size) as isize; + inside_patch[index] + || (-buffer..=buffer).any(|dy| { + (-buffer..=buffer).any(|dx| { + if dx.abs() + dy.abs() > buffer { + return false; + } + + let neighbor_x = x + dx; + let neighbor_y = y + dy; + neighbor_x >= 0 && neighbor_x < size as isize && neighbor_y >= 0 && neighbor_y < size as isize && inside_patch[neighbor_y as usize * size + neighbor_x as usize] + }) + }) + }) + .collect::>(); + + let mut inverse_uvs = vec![None::; size * size]; + let mut attempted = vec![false; size * size]; + let mut reseed_attempted = vec![false; size * size]; + let mut inside_queue = VecDeque::new(); + let mut outside_queue = VecDeque::new(); + + // Seed the first interior texel from the coarse inverse samples. + if let Some(index) = inside_patch.iter().position(|&inside| inside) { + let (_, target_position_in_mesh) = target_positions(index); + attempted[index] = true; + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv_from_seeds(target_position_in_mesh)) { + inverse_uvs[index] = Some(uv); + inside_queue.push_back(index); + } + } + + let neighbors = |x: isize, y: isize| [(0, -1), (-1, 0), (1, 0), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)].into_iter().map(move |(dx, dy)| (x + dx, y + dy)); + let out_of_map_range = |x: isize, y: isize| x < 0 || x >= size as isize || y < 0 || y >= size as isize; + + // Resolve the patch interior first, deferring successfully inverted exterior texels until it is complete. + loop { + while let Some(index) = inside_queue.pop_front() { + let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); + let x = (index % size) as isize; + let y = (index / size) as isize; + + for (neighbor_x, neighbor_y) in neighbors(x, y) { + if out_of_map_range(neighbor_x, neighbor_y) { + continue; + } + + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; + + if attempted[neighbor_index] || !sampled_region[neighbor_index] { + continue; + } + + attempted[neighbor_index] = true; + let (_, target_position_in_mesh) = target_positions(neighbor_index); + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[neighbor_index] = Some(uv); + if inside_patch[neighbor_index] { + inside_queue.push_back(neighbor_index); + } else { + outside_queue.push_back(neighbor_index); + } + } + } + } + + // Rasterizing the patch at the displacement-map resolution can split its interior into disconnected regions. + // If any interior texels remain unresolved, restart the inverse search using the initial UV seeds. + let next_seed = inverse_uvs + .iter() + .enumerate() + .find(|(index, result)| inside_patch[*index] && result.is_none() && !reseed_attempted[*index]) + .map(|(index, _)| index); + + let Some(next_seed) = next_seed else { break }; + + reseed_attempted[next_seed] = true; + attempted[next_seed] = true; + + let (_, target_position_in_mesh) = target_positions(next_seed); + let initial_uv = initial_uv_from_seeds(target_position_in_mesh); + + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[next_seed] = Some(uv); + inside_queue.push_back(next_seed); + } + } + + // Continue only through the exterior filtering region after every reachable interior texel is resolved. + while let Some(index) = outside_queue.pop_front() { + let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); + let x = (index % size) as isize; + let y = (index / size) as isize; + + for (neighbor_x, neighbor_y) in neighbors(x, y) { + if out_of_map_range(neighbor_x, neighbor_y) { + continue; + } + + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; + + if attempted[neighbor_index] || !sampled_region[neighbor_index] || inside_patch[neighbor_index] { + continue; + } + + attempted[neighbor_index] = true; + let (_, target_position_in_mesh) = target_positions(neighbor_index); + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[neighbor_index] = Some(uv); + outside_queue.push_back(neighbor_index); + } + } + } + + // As a fallback, fill unresolved buffer texels using the source UV of the nearest resolved interior texel + let resolved_inside_samples = (0..size * size) + .filter_map(|index| (inside_patch[index]).then(|| inverse_uvs[index].map(|uv| (index, uv))).flatten()) + .collect::>(); + for index in 0..size * size { + if !sampled_region[index] || inside_patch[index] || inverse_uvs[index].is_some() { + continue; + } + + let x = index % size; + let y = index / size; + + let nearest_uv = resolved_inside_samples + .iter() + .min_by_key(|(candidate, _)| { + let candidate_x = candidate % size; + let candidate_y = candidate / size; + let dx = x.abs_diff(candidate_x); + let dy = y.abs_diff(candidate_y); + dx * dx + dy * dy + }) + .map(|(_, uv)| uv.clamp(DVec2::ZERO, DVec2::ONE)); + + if let Some(uv) = nearest_uv { + inverse_uvs[index] = Some(uv); + } + } + + let displacements = inverse_uvs + .into_iter() + .enumerate() + .map(|(index, inverse_uv)| { + let (target_position, _) = target_positions(index); + // For positions outside the buffer, use zero displacement rather than estimating from a non-converged numerical source. + // This prevents unexpected jumps in the displacement that would increase the quantization scale. + let source_position = inverse_uv.map(|uv| uv.clamp(DVec2::ZERO, DVec2::ONE) * patch_extent).unwrap_or(target_position); + + source_position - target_position + }) + .collect(); + DisplacementMapSamples { + displacements, + region: [map_min.x, map_min.y, map_size.x, map_size.y], + } +} + +/// Encodes target-to-source displacement samples as an RGBA8 PNG for feDisplacementMap. +pub(super) fn displacements_to_map_png(displacements: &[DVec2], scale: f64) -> Option> { + let mut rgba8_bytes = Vec::with_capacity(DISPLACEMENT_MAP_SIZE * DISPLACEMENT_MAP_SIZE * 4); + + let encode_displacement = |displacement: DVec2| { + let max_channel = u8::MAX as f64; + let encoded = (DVec2::splat(0.5) + displacement / scale) * max_channel; + (encoded.x.round().clamp(0., max_channel) as u8, encoded.y.round().clamp(0., max_channel) as u8) + }; + + for displacement in displacements { + let (red, green) = encode_displacement(*displacement); + rgba8_bytes.extend_from_slice(&[red, green, 0, u8::MAX]); + } + + let mut displacement_map_png = Vec::new(); + ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) + .write_image(&rgba8_bytes, DISPLACEMENT_MAP_SIZE as u32, DISPLACEMENT_MAP_SIZE as u32, ::image::ExtendedColorType::Rgba8) + .ok()?; + + Some(displacement_map_png) +} + +// SVG gradient definitions + +/// Returns an SVG gradient stop element for the given gamma-encoded color. +fn gradient_stop_element(offset: f32, opacity: f32, gamma_color: [f32; 4]) -> String { + let offset = (offset.clamp(0., 1.) * 1_000_000.).round() / 1_000_000.; + let opacity = (opacity.clamp(0., 1.) * 1000.).round() / 1000.; + format!( + r##""##, + gamma_color_to_srgba8(gamma_color).to_rgb_hex(), + ) +} + +/// Returns SVG gradient stops that approximate a scalar alpha function. +pub(super) fn alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { + let error_func = |a: f32, b: f32| (a - b).abs(); + linear_approximation_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(arg, result)| gradient_stop_element(arg, result, Color::WHITE.to_gamma_srgb_channels())) + .collect::() +} + +/// Encodes a u-direction color curve as adaptively sampled SVG gradient stops. +pub(super) fn u_color_curve_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { + let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + linear_approximation_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(argument, result)| gradient_stop_element(argument, 1., result.to_array())) + .collect::() +} + +/// Encodes the two stops a clamped ramp needs, for a gradient placed across the range it ramps over. +pub(super) fn clamped_ramp_gradient_stops_string() -> String { + let white = Color::WHITE.to_gamma_srgb_channels(); + format!("{}{}", gradient_stop_element(0., 1., white), gradient_stop_element(1., 0., white)) +} + +/// Encodes a scalar alpha curve as an opaque grayscale gradient for use by a luminance mask. +pub(super) fn u_alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { + let error_func = |a: f32, b: f32| (a - b).abs(); + linear_approximation_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(offset, alpha)| gradient_stop_element(offset, 1., [alpha, alpha, alpha, 1.])) + .collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + use vector_types::gradient::MeshGradient; + + /// Builds a mesh whose corners cycle through the given colors. + fn mesh_with_corner_colors(colors: [Color; 4]) -> MeshGradient { + let mut mesh = MeshGradient::default(); + for corner_index in 0..mesh.size() { + mesh.set_corner_color(corner_index, colors[corner_index % colors.len()]).unwrap(); + } + mesh + } + + #[test] + fn stacked_oklab_rows_reproduce_the_color_surface() { + let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); + let evaluator = mesh.evaluator(GradientSpace::OkLab, GradientInterpolation::Smooth).unwrap(); + let layers = SvgMeshVLayers::new(&evaluator); + + let mut worst_error = 0_f32; + for patch in evaluator.patches() { + for u_step in 0..=256 { + let u = u_step as f32 / 256.; + for v_step in 0..=256 { + let v = v_step as f32 / 256.; + + let mut composited = layers.evaluate_layer_u_color(patch, layers.layer_count() - 1, u); + for index in (0..layers.layer_count() - 1).rev() { + let alpha = layers.source_over_alpha(index, v); + composited = composited.lerp(layers.evaluate_layer_u_color(patch, index, u), alpha); + } + + let expected = Vec4::from_array(patch.evaluate_color(u, v)); + worst_error = worst_error.max((expected - composited).abs().max_element()); + } + } + } + + assert!(worst_error <= SVG_LAYER_ERROR_TOLERANCE, "the stack deviated by {} of 1/255", worst_error * 255.); + } + + #[test] + fn oklab_row_weights_stay_a_partition_of_unity() { + let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); + let evaluator = mesh.evaluator(GradientSpace::OkLab, GradientInterpolation::Smooth).unwrap(); + let layers = SvgMeshVLayers::new(&evaluator); + + for v_step in 0..=64 { + let v = v_step as f32 / 64.; + let mut remaining = 1_f32; + let mut total = 0_f32; + for index in 0..layers.layer_count() - 1 { + let alpha = layers.source_over_alpha(index, v); + assert!((0. ..=1.).contains(&alpha), "a source-over alpha must stay in range, got {alpha} at v={v}"); + total += remaining * alpha; + remaining -= remaining * alpha; + } + total += remaining; + + assert!((total - 1.).abs() < 1e-5, "the weights must sum to one, got {total} at v={v}"); + } + } + + #[test] + fn adaptive_subdivision_accounts_for_color_error() { + let mesh = MeshGradient::default(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); + let geometry_only = subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); + let with_color = subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); + + assert!(with_color.len() > geometry_only.len()); + } + + #[test] + fn adaptive_subdivision_rejects_non_finite_transform() { + let mesh = MeshGradient::default(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); + let non_finite_transform = DAffine2::from_scale(DVec2::splat(f64::NAN)); + + assert!(subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); + } +} diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9ba8099e342..72ec21c8f11 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -5,6 +5,8 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshGradientSurface, MeshPatch, initial_mesh_gradient_transform_for_bounding_box}; + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -470,6 +472,16 @@ fn color_from_space_channels(channels: [f64; 4]) -> Color Color::from_rgbaf32_unchecked(red, green, blue, channels[3] as f32) } +/// A color's channels in the selected gradient color space, alongside its straight alpha. +pub(crate) fn gradient_space_channels(color: Color, space: GradientSpace) -> [f32; 4] { + with_space!(space, space_channels, color).map(|channel| channel as f32) +} + +/// Converts selected gradient color-space channels and straight alpha back into `Color`. +pub(crate) fn color_from_gradient_space_channels(channels: [f32; 4], space: GradientSpace) -> Color { + with_space!(space, color_from_space_channels, channels.map(|channel| channel as f64)) +} + /// The channel carrying hue in a polar space, or `None` for a rectangular one. fn space_hue_index() -> Option { match CS::LAYOUT { diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index a025562eb0f..b7a96c0c5ef 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -3,11 +3,14 @@ extern crate log; pub mod gradient; pub mod math; +pub mod mesh_gradient; pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop}; +pub use gradient::{ + Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop, MeshGradient, MeshGradientSurface, +}; pub use math::QuadExt; pub use vector::Vector; pub use vector::reference_point::ReferencePoint; diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs new file mode 100644 index 00000000000..a4fd0f03d4f --- /dev/null +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -0,0 +1,1771 @@ +use std::array; +use std::ops::{Add, Deref, Mul, Sub}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use core_types::bounds::RenderBoundingBox; +use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; +use core_types::{Color, render_complexity::RenderComplexity}; +use dyn_any::DynAny; +use glam::{DAffine2, DMat2, DVec2, Mat4, Vec4}; +use kurbo::{BezPath, CubicBez, ParamCurve, PathSeg}; +use num_traits::Float; + +use crate::{ + Vector, + gradient::{GradientInterpolation, GradientSpace, color_from_gradient_space_channels, gradient_space_channels}, + vector::{ + PointId, SegmentId, + misc::{BezierHandles, HandleId, HandleType, pathseg_points, point_to_dvec2}, + }, +}; + +// ============= +// Mesh Gradient +// ============= + +/// 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, + corner_points: MeshGrid, + corner_colors: MeshGrid, + horizontal_edges: MeshGrid, + vertical_edges: MeshGrid, + #[cache_hash(skip)] + #[cfg_attr(feature = "serde", serde(skip, default))] + evaluator_cache: MeshGradientEvaluatorCache, +} + +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 { + /// Create a new mesh gradient alternates black and white from the provided row-major corner positions. + 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 mesh_geometry = Vector::default(); + let mut corner_points = Vec::with_capacity(corner_count); + + for &position in positions { + let point_id = mesh_geometry.point_domain.next_id(); + mesh_geometry.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 = mesh_geometry.segment_domain.next_id(); + mesh_geometry.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + line_to_cubic_bezier_handles(positions[start_index], positions[end_index]), + ); + 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 = mesh_geometry.segment_domain.next_id(); + mesh_geometry.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + line_to_cubic_bezier_handles(positions[start_index], positions[end_index]), + ); + vertical_edges.push(segment_id); + } + } + + let colors = [Color::RED, Color::GREEN, Color::BLUE, Color::GREEN]; + let corner_colors = (0..corner_rows) + .flat_map(|row| { + (0..corner_columns).map(move |column| { + let corner_index = row * corner_columns + column; + let color_index = corner_index % colors.len(); + colors[color_index] + }) + }) + .collect(); + + Some(Self { + mesh_geometry, + corner_points: MeshGrid::new(corner_points, corner_rows, corner_columns)?, + corner_colors: MeshGrid::new(corner_colors, corner_rows, corner_columns)?, + horizontal_edges: MeshGrid::new(horizontal_edges, corner_rows, corner_columns - 1)?, + vertical_edges: MeshGrid::new(vertical_edges, corner_rows - 1, corner_columns)?, + evaluator_cache: MeshGradientEvaluatorCache::default(), + }) + } + + /// Returns the number of corners. + pub fn size(&self) -> usize { + self.corner_points.rows * self.corner_points.columns + } + + /// Returns resolved patch by the provided row/column position, if any. + fn patch(&self, row: usize, column: usize) -> Option { + let patch_columns = self.corner_points.columns.saturating_sub(1); + let index = row * patch_columns + column; + + let top_left_id = *self.corner_points.get(row, column)?; + let top_right_id = *self.corner_points.get(row, column + 1)?; + let bottom_left_id = *self.corner_points.get(row + 1, column)?; + let bottom_right_id = *self.corner_points.get(row + 1, column + 1)?; + + 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(row, column)?, + *self.corner_colors.get(row, column + 1)?, + *self.corner_colors.get(row + 1, column)?, + *self.corner_colors.get(row + 1, column + 1)?, + ]; + + let top_edge_id = *self.horizontal_edges.get(row, column)?; + let bottom_edge_id = *self.horizontal_edges.get(row + 1, column)?; + let left_edge_id = *self.vertical_edges.get(row, column)?; + let right_edge_id = *self.vertical_edges.get(row, column + 1)?; + + let edges = [ + self.mesh_geometry.segment_from_id(top_edge_id)?, + self.mesh_geometry.segment_from_id(bottom_edge_id)?, + self.mesh_geometry.segment_from_id(left_edge_id)?, + self.mesh_geometry.segment_from_id(right_edge_id)?, + ]; + + Some(MeshPatch { index, corners, colors, edges }) + } + + /// The union of every resolvable patch outline, in mesh-local coordinates. + pub fn boundary_path(&self) -> BezPath { + let mut boundary = BezPath::new(); + for patch in self.patches().flatten() { + boundary.extend(patch.boundary_path()); + } + boundary + } + + /// Iterator over all of the mesh gradient patches by row-major order, `None` if the patch is defined in unexpected structure. + pub fn patches(&self) -> impl Iterator> + '_ { + let patch_rows = self.corner_points.rows.saturating_sub(1); + let patch_columns = self.corner_points.columns.saturating_sub(1); + (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) + } + + /// Returns the read only mesh gradient's geometry. + pub fn geometry(&self) -> &Vector { + &self.mesh_geometry + } + + /// Returns an iterator of all corners data by row-major order. + pub fn corners(&self) -> impl Iterator + '_ { + self.corner_points + .values + .iter() + .copied() + .zip(self.corner_colors.values.iter().copied()) + .enumerate() + .filter_map(|(index, (point_id, color))| { + let position = self.mesh_geometry.point_domain.position_from_id(point_id)?; + Some(MeshGradientCorner { index, point_id, position, color }) + }) + } + + /// Returns an iterator of all edges data by row-major order. + pub fn edges(&self) -> impl Iterator + '_ { + self.mesh_geometry + .segment_iter() + .map(|(segment_id, segment, start, end)| MeshGradientEdge { segment_id, segment, start, end }) + } + + /// Set the corner position by flat corner index. The corresponding handles are also moved same amount. + pub fn set_corner_position(&mut self, corner_index: usize, position: DVec2) -> Option<()> { + let point_id = *self.corner_points.get_flat(corner_index)?; + let point_index = self.mesh_geometry.point_domain.resolve_id(point_id)?; + let previous_position = *self.mesh_geometry.point_domain.positions().get(point_index)?; + let delta = position - previous_position; + + for (_, handles, start, end) in self.mesh_geometry.handles_mut() { + if start == point_id { + handles.move_start(delta); + } + if end == point_id { + handles.move_end(delta); + } + } + + self.mesh_geometry.point_domain.set_position(point_index, position); + + self.evaluator_cache.invalidate(); + Some(()) + } + + /// Set the corner color by flat corner index. + pub fn set_corner_color(&mut self, corner_index: usize, color: Color) -> Option<()> { + *self.corner_colors.get_flat_mut(corner_index)? = color; + + self.evaluator_cache.invalidate(); + Some(()) + } + + pub fn set_edge_handles(&mut self, segment_id: SegmentId, new_handles: BezierHandles) -> Option<()> { + let (_, handles, _, _) = self.mesh_geometry.handles_mut().find(|(id, _, _, _)| *id == segment_id)?; + *handles = new_handles; + + self.evaluator_cache.invalidate(); + Some(()) + } + + pub fn set_handle_position(&mut self, handle_id: HandleId, new_position: DVec2) -> Option<()> { + let (_, handles, _, _) = self.mesh_geometry.handles_mut().find(|(segment_id, _, _, _)| *segment_id == handle_id.segment)?; + + match (handle_id.ty, handles) { + (HandleType::Primary, BezierHandles::Quadratic { handle }) => { + *handle = new_position; + } + (HandleType::Primary, BezierHandles::Cubic { handle_start, .. }) => { + *handle_start = new_position; + } + (HandleType::End, BezierHandles::Cubic { handle_end, .. }) => { + *handle_end = new_position; + } + _ => return None, + } + + self.evaluator_cache.invalidate(); + Some(()) + } + + /// Finds which grid axis contains the segment and its patch index along that axis. + fn grid_line_axis(&self, segment_id: SegmentId) -> Option<(MeshGridLineAxis, usize)> { + let (axis, split_patch_index) = if let Some(index) = self.horizontal_edges.values.iter().position(|&id| id == segment_id) { + (MeshGridLineAxis::Column, index % self.horizontal_edges.columns) + } else { + let index = self.vertical_edges.values.iter().position(|&id| id == segment_id)?; + (MeshGridLineAxis::Row, index / self.vertical_edges.columns) + }; + + Some((axis, split_patch_index)) + } + + /// Inserts a new grid line through the provided segment at the given parameter. The time has to be within (0, 1). + pub fn insert_grid_line(&mut self, segment_id: SegmentId, space: GradientSpace, interpolation: GradientInterpolation, time: f64) -> Option<()> { + #[derive(Clone, Copy)] + struct SegmentToSplit { + segment_id: SegmentId, + start_point_id: PointId, + end_point_id: PointId, + segment: PathSeg, + } + + if !(0. < time && time < 1.) { + return None; + } + + let evaluator = self.evaluator(space, interpolation).ok()?; + let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; + let (split_edge_grid, _) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); + let [across_corner_count, _] = axis.logical_indices(split_edge_grid.rows, split_edge_grid.columns); + let grid_line_insertion_index = split_patch_index + 1; + let across_patch_count = across_corner_count - 1; + let patch_columns = self.corner_points.columns - 1; + + // Collect the existing segments that will be split by inserting new corners + let segments_to_split: Vec = (0..across_corner_count) + .map(|across| { + let [edge_row, edge_column] = axis.physical_indices(across, split_patch_index); + let segment_id = *split_edge_grid.get(edge_row, edge_column)?; + let (start_point_id, end_point_id, segment) = self.mesh_geometry.segment_points_from_id(segment_id)?; + Some(SegmentToSplit { + segment_id, + start_point_id, + end_point_id, + segment, + }) + }) + .collect::>()?; + + // Calculate the new corners' information + let new_corner_positions: Vec = segments_to_split.iter().map(|source| point_to_dvec2(source.segment.eval(time))).collect(); + let new_corner_colors: Vec = (0..across_corner_count) + .map(|across| { + let (patch_across, across_t) = if across < across_patch_count { (across, 0.) } else { (across - 1, 1.) }; + let [patch_row, patch_column] = axis.physical_indices(patch_across, split_patch_index); + let patch_index = patch_row * patch_columns + patch_column; + let [u, v] = axis.uv(time as f32, across_t); + let [r, g, b, a] = evaluator.patch(patch_index).unwrap().evaluate_color(u, v); + Color::from_gamma_srgb_channels(r, g, b, a) + }) + .collect(); + + let mut new_corner_ids = Vec::with_capacity(across_corner_count); + for &position in &new_corner_positions { + let point_id = self.mesh_geometry.point_domain.next_id(); + self.mesh_geometry.point_domain.push(point_id, position); + new_corner_ids.push(point_id); + } + + // Split the existing segments by the new corners + let mut first_split_edges = Vec::with_capacity(across_corner_count); + let mut second_split_edges = Vec::with_capacity(across_corner_count); + for (source, &inserted_corner) in segments_to_split.iter().zip(&new_corner_ids) { + let first_half = pathseg_points(source.segment.subsegment(0. ..time)); + let second_half = pathseg_points(source.segment.subsegment(time..1.)); + + let first_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push(first_segment_id, source.start_point_id, inserted_corner, (first_half.p1, first_half.p2)); + first_split_edges.push(first_segment_id); + + let second_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push(second_segment_id, inserted_corner, source.end_point_id, (second_half.p1, second_half.p2)); + second_split_edges.push(second_segment_id); + } + + // Create new segments along the axis + let mut connecting_edges = Vec::with_capacity(across_patch_count); + for (corner_pair, position_pair) in new_corner_ids.windows(2).zip(new_corner_positions.windows(2)) { + let &[start, end] = corner_pair else { unreachable!() }; + let &[start_position, end_position] = position_pair else { unreachable!() }; + let connecting_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push(connecting_segment_id, start, end, line_to_cubic_bezier_handles(start_position, end_position)); + connecting_edges.push(connecting_segment_id); + } + + self.corner_points.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&new_corner_ids])?; + self.corner_colors.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&new_corner_colors])?; + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids_mut(&mut self.horizontal_edges, &mut self.vertical_edges); + split_edge_grid.splice_lines(axis, split_patch_index..grid_line_insertion_index, &[&first_split_edges, &second_split_edges])?; + connecting_edge_grid.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&connecting_edges])?; + + let replaced_edges: Vec<_> = segments_to_split.iter().map(|source| source.segment_id).collect(); + let point_count = self.mesh_geometry.point_domain.ids().len(); + self.mesh_geometry.segment_domain.retain(|id| !replaced_edges.contains(id), point_count); + + self.evaluator_cache.invalidate(); + Some(()) + } + + /// Removes the interior grid line containing the provided segment. + pub fn remove_edge(&mut self, segment_id: SegmentId) -> Option<()> { + let (axis, grid_line_index) = if let Some(index) = self.horizontal_edges.values.iter().position(|&id| id == segment_id) { + (MeshGridLineAxis::Row, index / self.horizontal_edges.columns) + } else { + let index = self.vertical_edges.values.iter().position(|&id| id == segment_id)?; + (MeshGridLineAxis::Column, index % self.vertical_edges.columns) + }; + + let [across_corner_count, grid_line_count] = axis.logical_indices(self.corner_points.rows, self.corner_points.columns); + if grid_line_index == 0 || grid_line_index + 1 >= grid_line_count { + return None; + } + + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); + let removed_corner_ids: Vec = (0..across_corner_count) + .map(|across| { + let [row, column] = axis.physical_indices(across, grid_line_index); + self.corner_points.get(row, column).copied() + }) + .collect::>()?; + + let mut merged_edges = Vec::with_capacity(across_corner_count); + let mut removed_edge_ids = Vec::with_capacity(across_corner_count * 2 + across_corner_count - 1); + for across in 0..across_corner_count { + let [first_row, first_column] = axis.physical_indices(across, grid_line_index - 1); + let [second_row, second_column] = axis.physical_indices(across, grid_line_index); + let first_segment_id = *split_edge_grid.get(first_row, first_column)?; + let second_segment_id = *split_edge_grid.get(second_row, second_column)?; + let (start_point_id, _, first_segment) = self.mesh_geometry.segment_points_from_id(first_segment_id)?; + let (_, end_point_id, second_segment) = self.mesh_geometry.segment_points_from_id(second_segment_id)?; + let [first_segment, second_segment] = [first_segment, second_segment].map(|segment| segment.to_cubic()); + + // Each half's control point was shortened by the split that produced it, + // so scale it back out by the share of the merged parameter range that half covers. + let merged_handles = { + let [first_start, first_end] = [first_segment.p0, first_segment.p3].map(point_to_dvec2); + let [second_start, second_end] = [second_segment.p0, second_segment.p3].map(point_to_dvec2); + let first_chord = first_start.distance(first_end); + let second_chord = second_start.distance(second_end); + let total_chord = first_chord + second_chord; + let split = if total_chord > 0. { (first_chord / total_chord).clamp(0.1, 0.9) } else { 0.5 }; + + let handle_start = first_start + (point_to_dvec2(first_segment.p1) - first_start) / split; + let handle_end = second_end + (point_to_dvec2(second_segment.p2) - second_end) / (1. - split); + (Some(handle_start), Some(handle_end)) + }; + + let merged_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push(merged_segment_id, start_point_id, end_point_id, merged_handles); + merged_edges.push(merged_segment_id); + removed_edge_ids.extend([first_segment_id, second_segment_id]); + } + + for across in 0..across_corner_count - 1 { + let [row, column] = axis.physical_indices(across, grid_line_index); + removed_edge_ids.push(*connecting_edge_grid.get(row, column)?); + } + + self.corner_points.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + self.corner_colors.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids_mut(&mut self.horizontal_edges, &mut self.vertical_edges); + split_edge_grid.splice_lines(axis, grid_line_index - 1..grid_line_index + 1, &[&merged_edges])?; + connecting_edge_grid.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + + let point_count = self.mesh_geometry.point_domain.ids().len(); + self.mesh_geometry.segment_domain.retain(|id| !removed_edge_ids.contains(id), point_count); + let Vector { point_domain, segment_domain, .. } = &mut self.mesh_geometry; + point_domain.retain(segment_domain, |id| !removed_corner_ids.contains(id)); + + self.evaluator_cache.invalidate(); + Some(()) + } + + /// Returns a `Arc` to evaluate position or color in the mesh. + pub fn evaluator(&self, space: GradientSpace, interpolation: GradientInterpolation) -> Result, MeshGradientEvaluatorError> { + if space.is_polar() { + return Err(MeshGradientEvaluatorError::UnsupportedColorSpace); + } + self.evaluator_cache.get_or_init(self, space, interpolation) + } +} + +impl RenderComplexity for MeshGradient { + fn render_complexity(&self) -> usize { + usize::MAX + } +} + +impl core_types::bounds::BoundingBox for MeshGradient { + fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let mut mesh_min = DVec2::MAX; + let mut mesh_max = DVec2::MIN; + + let evaluator = { + let mut guard = self.evaluator_cache.get_clean_guard(); + match guard.as_ref() { + Some(cached) => Arc::clone(cached), + None => { + let Ok(fresh_evaluator) = self + .evaluator_cache + .create_fresh_evaluator(&mut guard, self, GradientSpace::default(), GradientInterpolation::default()) + else { + return RenderBoundingBox::None; + }; + fresh_evaluator + } + } + }; + + for patch_evaluator in evaluator.patches() { + let [patch_min, patch_max] = patch_evaluator.position_bezier_net().control_net_bounds(transform); + mesh_min = mesh_min.min(patch_min); + mesh_max = mesh_max.max(patch_max); + } + + RenderBoundingBox::Rectangle([mesh_min, mesh_max]) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + core_types::bounds::BoundingBox::bounding_box(self, transform, include_stroke) + } +} + +// =============================================== +// MeshPatch, MeshGradientCorner, MeshGradientEdge +// =============================================== + +/// Resolved patch of a mesh gradient. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshPatch { + /// Patch index in row-major order. + pub index: usize, + /// 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], +} + +impl MeshPatch { + /// The patch outline as one closed subpath, in mesh-local coordinates. + /// Walks `top`, `right`, then `bottom` and `left` reversed, which is the only traversal of [`Self::edges`]'s + /// `[top, bottom, left, right]` order that stays connected end-to-end. + pub fn boundary_path(&self) -> BezPath { + let [top, bottom, left, right] = self.edges; + let mut boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + boundary.close_path(); + boundary + } + + /// Checks for foldovers by sampling the position Jacobian over the patch. + pub fn sampled_no_foldover(&self) -> bool { + const SUBDIVISIONS: usize = 64; + const RELATIVE_EPSILON: f64 = 1e-6; + const FOLDOVER_SAFETY_ANGLE_DEGREES: f64 = 5.; + let minimum_normalized_jacobian = FOLDOVER_SAFETY_ANGLE_DEGREES.to_radians().sin(); + let position_bezier_net = coons_to_position_bezier_net(&self.corners, &self.edges); + + for row in 0..=SUBDIVISIONS { + let v = row as f64 / SUBDIVISIONS as f64; + for column in 0..=SUBDIVISIONS { + let u = column as f64 / SUBDIVISIONS as f64; + let jacobian = position_jacobian(&position_bezier_net, u, v); + let derivative_u = jacobian.x_axis; + let derivative_v = jacobian.y_axis; + let scale = derivative_u.length() * derivative_v.length(); + let determinant = derivative_u.perp_dot(derivative_v); + + if !scale.is_finite() || !determinant.is_finite() || determinant <= (RELATIVE_EPSILON + minimum_normalized_jacobian) * scale { + return false; + } + } + } + + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshGradientCorner { + pub index: usize, + pub point_id: PointId, + pub position: DVec2, + pub color: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshGradientEdge { + pub segment_id: SegmentId, + pub segment: PathSeg, + pub start: PointId, + pub end: PointId, +} + +// ================================= +// MeshGrid (Internal grid managers) +// ================================= + +/// Row-major storage for values arranged in a rectangular mesh grid. +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +struct MeshGrid { + rows: usize, + columns: usize, + values: Vec, +} + +impl MeshGrid { + fn new(values: Vec, rows: usize, columns: usize) -> Option { + (values.len() == rows.checked_mul(columns)?).then_some(Self { rows, columns, values }) + } + + fn index(&self, row: usize, column: usize) -> Option { + if row >= self.rows || column >= self.columns { + return None; + } + row.checked_mul(self.columns)?.checked_add(column) + } + + fn get(&self, row: usize, column: usize) -> Option<&T> { + self.values.get(self.index(row, column)?) + } + + fn get_flat(&self, index: usize) -> Option<&T> { + self.values.get(index) + } + + fn get_flat_mut(&mut self, index: usize) -> Option<&mut T> { + self.values.get_mut(index) + } + + fn dimensions(&self) -> [usize; 2] { + [self.rows, self.columns] + } + + fn splice_lines(&mut self, axis: MeshGridLineAxis, removed: std::ops::Range, inserted_lines: &[&[T]]) -> Option<()> + where + T: Copy, + { + let [across_count, along_count] = axis.logical_indices(self.rows, self.columns); + if removed.start > removed.end || removed.end > along_count || inserted_lines.iter().any(|line| line.len() != across_count) { + return None; + } + + let removed_count = removed.end - removed.start; + let inserted_count = inserted_lines.len(); + let new_along_count = along_count - removed_count + inserted_count; + let [new_rows, new_columns] = axis.physical_indices(across_count, new_along_count); + let mut new_values = Vec::with_capacity(new_rows.checked_mul(new_columns)?); + + for new_row in 0..new_rows { + for new_column in 0..new_columns { + let [across, along] = axis.logical_indices(new_row, new_column); + if along >= removed.start && along < removed.start + inserted_count { + new_values.push(inserted_lines[along - removed.start][across]); + } else { + let original_along = if along < removed.start { along } else { along - inserted_count + removed_count }; + let [original_row, original_column] = axis.physical_indices(across, original_along); + new_values.push(self.values[original_row * self.columns + original_column]); + } + } + } + + self.rows = new_rows; + self.columns = new_columns; + self.values = new_values; + Some(()) + } +} + +/// Maps row and column insertion onto one operation that splits edges along an axis and connects them across the other axis. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MeshGridLineAxis { + Row, + Column, +} + +impl MeshGridLineAxis { + fn physical_indices(self, across: usize, along: usize) -> [usize; 2] { + match self { + Self::Column => [across, along], + Self::Row => [along, across], + } + } + + fn logical_indices(self, row: usize, column: usize) -> [usize; 2] { + match self { + Self::Column => [row, column], + Self::Row => [column, row], + } + } + + fn uv(self, along: f32, across: f32) -> [f32; 2] { + match self { + Self::Column => [along, across], + Self::Row => [across, along], + } + } + + fn edge_grids<'a, T>(self, horizontal: &'a MeshGrid, vertical: &'a MeshGrid) -> (&'a MeshGrid, &'a MeshGrid) { + match self { + Self::Column => (horizontal, vertical), + Self::Row => (vertical, horizontal), + } + } + + fn edge_grids_mut<'a, T>(self, horizontal: &'a mut MeshGrid, vertical: &'a mut MeshGrid) -> (&'a mut MeshGrid, &'a mut MeshGrid) { + match self { + Self::Column => (horizontal, vertical), + Self::Row => (vertical, horizontal), + } + } +} + +/// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields serialized only when non-default. +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MeshGradientSurface { + pub mesh: MeshGradient, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientSpace::is_default"))] + pub gradient_space: GradientSpace, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientInterpolation::is_default"))] + pub gradient_interpolation: GradientInterpolation, +} + +impl Default for MeshGradientSurface { + fn default() -> Self { + Self { + mesh: MeshGradient::default(), + gradient_space: GradientSpace::default(), + gradient_interpolation: GradientInterpolation::Smooth, + } + } +} + +impl From for MeshGradientSurface { + fn from(mesh: MeshGradient) -> Self { + Self { mesh, ..Default::default() } + } +} + +// The runtime wire form: whole-mesh settings ride as the mesh gradient item's attributes in its containing list, +// where the Fill kernel, chain setter nodes, and renderers read and write them +impl From for Item { + fn from(surface: MeshGradientSurface) -> Self { + let mut item = Item::new_from_element(surface.mesh); + if !surface.gradient_space.is_default() { + item.set_attribute(ATTR_GRADIENT_SPACE, surface.gradient_space); + } + if !surface.gradient_interpolation.is_default() { + item.set_attribute(ATTR_GRADIENT_INTERPOLATION, surface.gradient_interpolation); + } + item + } +} + +impl From<&Item> for MeshGradientSurface { + fn from(item: &Item) -> Self { + Self { + mesh: item.element().clone(), + gradient_space: item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE), + gradient_interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION), + } + } +} + +// ==================== +// Bicubic Bezier patch +// ==================== + +pub trait Lerp { + fn lerp(self, rhs: Self, time: f64) -> Self; + fn midpoint(self, rhs: Self) -> Self; +} + +impl Lerp for DVec2 { + fn lerp(self, rhs: Self, time: f64) -> Self { + DVec2::lerp(self, rhs, time) + } + fn midpoint(self, rhs: Self) -> Self { + DVec2::midpoint(self, rhs) + } +} + +impl Lerp for Vec4 { + fn lerp(self, rhs: Self, time: f64) -> Self { + Vec4::lerp(self, rhs, time as f32) + } + fn midpoint(self, rhs: Self) -> Self { + Vec4::midpoint(self, rhs) + } +} + +#[derive(Copy, Clone)] +pub struct BicubicBezierNet([[T; 4]; 4]); + +impl Deref for BicubicBezierNet { + type Target = [[T; 4]; 4]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> Sub for BicubicBezierNet { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + BicubicBezierNet(array::from_fn(|v| array::from_fn(|u| self[v][u] - rhs[v][u]))) + } +} + +impl BicubicBezierNet { + pub fn from_quadrilateral(corners: &[T; 4]) -> Self { + let [top_left, top_right, bottom_left, bottom_right] = *corners; + BicubicBezierNet(array::from_fn(|v_index| { + let v = v_index as f64 / 3.; + let left = top_left.lerp(bottom_left, v); + let right = top_right.lerp(bottom_right, v); + array::from_fn(|u_index| { + let u = u_index as f64 / 3.; + left.lerp(right, u) + }) + })) + } + + pub fn corners(&self) -> [T; 4] { + [self[0][0], self[0][3], self[3][0], self[3][3]] + } + + pub fn corners_clockwise(&self) -> [T; 4] { + [self[0][0], self[0][3], self[3][3], self[3][0]] + } + + pub fn subdivide(&self) -> [Self; 4] { + let subdivide_cubic_bezier = |points: &[T; 4]| { + let [p0, p1, p2, p3] = *points; + let a = p0.midpoint(p1); + let b = p1.midpoint(p2); + let c = p2.midpoint(p3); + let d = a.midpoint(b); + let e = b.midpoint(c); + let f = d.midpoint(e); + ([p0, a, d, f], [f, e, c, p3]) + }; + + let transpose = |m: [[T; 4]; 4]| -> [[T; 4]; 4] { std::array::from_fn(|i| std::array::from_fn(|j| m[j][i])) }; + + let split_rows = |m: &[[T; 4]; 4]| { + let split = m.map(|row| subdivide_cubic_bezier(&row)); + let first: [[T; 4]; 4] = std::array::from_fn(|i| split[i].0); + let second: [[T; 4]; 4] = std::array::from_fn(|i| split[i].1); + (first, second) + }; + + let split_cols = |m: [[T; 4]; 4]| { + let (top, bottom) = split_rows(&transpose(m)); + (transpose(top), transpose(bottom)) + }; + + let (left, right) = split_rows(self); + let (top_left, bottom_left) = split_cols(left); + let (top_right, bottom_right) = split_cols(right); + + [Self(top_left), Self(top_right), Self(bottom_left), Self(bottom_right)] + } +} + +impl BicubicBezierNet { + pub fn control_net_bounds(&self, transform: DAffine2) -> [DVec2; 2] { + let mut bbox_min = DVec2::MAX; + let mut bbox_max = DVec2::MIN; + for &point_local in self.iter().flatten() { + let point = transform.transform_point2(point_local); + bbox_min = bbox_min.min(point); + bbox_max = bbox_max.max(point); + } + [bbox_min, bbox_max] + } +} + +impl BicubicBezierNet { + /// Convert to row-major array. Colors are also converted to array. + pub fn to_array(&self) -> [[f32; 4]; 16] { + let mut array = [[0.; 4]; 16]; + for row in 0..=3 { + for col in 0..=3 { + array[row * 4 + col] = self[row][col].to_array(); + } + } + array + } +} + +// ===================== +// MeshGradientEvaluator +// ===================== + +#[derive(Default)] +struct MeshGradientEvaluatorCache { + evaluator: Mutex>>, +} + +impl MeshGradientEvaluatorCache { + fn invalidate(&mut self) { + match self.evaluator.get_mut() { + Ok(slot) => *slot = None, + Err(poisoned) => *poisoned.into_inner() = None, + } + + self.evaluator.clear_poison(); + } + + fn get_clean_guard(&self) -> MutexGuard<'_, Option>> { + match self.evaluator.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = None; + self.evaluator.clear_poison(); + guard + } + } + } + + fn get_or_init(&self, mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result, MeshGradientEvaluatorError> { + let mut guard = self.get_clean_guard(); + + match guard.as_ref() { + Some(cached) => { + if cached.space == space && cached.interpolation == interpolation { + return Ok(Arc::clone(cached)); + } + self.create_fresh_evaluator(&mut guard, mesh_gradient, space, interpolation) + } + None => self.create_fresh_evaluator(&mut guard, mesh_gradient, space, interpolation), + } + } + + fn create_fresh_evaluator( + &self, + slot: &mut Option>, + mesh_gradient: &MeshGradient, + space: GradientSpace, + interpolation: GradientInterpolation, + ) -> Result, MeshGradientEvaluatorError> { + let evaluator = Arc::new(MeshGradientEvaluator::try_new(mesh_gradient, space, interpolation)?); + *slot = Some(Arc::clone(&evaluator)); + // log::debug!("created a fresh mesh gradient evaluator"); + Ok(evaluator) + } +} + +impl Clone for MeshGradientEvaluatorCache { + fn clone(&self) -> Self { + let guard = match self.evaluator.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = None; + self.evaluator.clear_poison(); + guard + } + }; + Self { + evaluator: Mutex::new(guard.as_ref().map(Arc::clone)), + } + } +} + +impl PartialEq for MeshGradientEvaluatorCache { + fn eq(&self, _other: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for MeshGradientEvaluatorCache { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("MeshGradientEvaluatorCache") + } +} + +#[derive(Clone, Copy)] +pub struct ColorDerivative { + pub u: Vec4, + pub v: Vec4, +} + +#[derive(Debug)] +pub enum MeshGradientEvaluatorError { + UnsupportedColorSpace, + InsufficientCornerGrid, + InconsistentGridDimensions, + MissingCornerPoint, + InvalidPatch, +} + +#[derive(Clone)] +struct PatchData { + /// Corner indices, [top-left, top-right, bottom-left, bottom-right]. + corner_indices: [usize; 4], + /// Bicubic Bezier surface representation of a patch. + position_bezier_net: BicubicBezierNet, +} + +#[derive(Clone)] +struct CornerData { + /// Position in the mesh space; [(0,0), (1,1)] + position: DVec2, + /// Color values in the selected color space. + color: Vec4, +} + +/// 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 { + patch_rows: usize, + patch_columns: usize, + /// Patch-closed data, row major order. + patches: Vec, + /// Corner-closed data, row major order. + corners: Vec, + /// The Bezier restatement of the Hermite color data. + color_bezier_nets: OnceLock>>, + space: GradientSpace, + interpolation: GradientInterpolation, +} + +impl MeshGradientEvaluator { + pub fn try_new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { + let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); + if corner_rows < 2 || corner_columns < 2 { + return Err(MeshGradientEvaluatorError::InsufficientCornerGrid); + } + let patch_columns = corner_columns - 1; + let patch_rows = corner_rows - 1; + + if mesh_gradient.corner_colors.dimensions() != [corner_rows, corner_columns] + || mesh_gradient.horizontal_edges.dimensions() != [corner_rows, patch_columns] + || mesh_gradient.vertical_edges.dimensions() != [patch_rows, corner_columns] + { + return Err(MeshGradientEvaluatorError::InconsistentGridDimensions); + } + + let corner_positions: Option> = mesh_gradient + .corner_points + .values + .iter() + .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) + .collect::>(); + let Some(corner_positions) = corner_positions else { + return Err(MeshGradientEvaluatorError::MissingCornerPoint); + }; + + let colors: Vec = mesh_gradient + .corner_colors + .values + .iter() + .map(|&color| Vec4::from_array(gradient_space_channels(color, space))) + .collect(); + + let corners = corner_positions.iter().zip(colors.iter()).map(|(&position, &color)| CornerData { position, color }).collect(); + + let mut patches: Vec = Vec::with_capacity(patch_rows * patch_columns); + for row in 0..patch_rows { + for column in 0..patch_columns { + let patch = mesh_gradient.patch(row, column).ok_or(MeshGradientEvaluatorError::InvalidPatch)?; + let top_left_index = row * corner_columns + column; + let corner_indices = [top_left_index, top_left_index + 1, top_left_index + corner_columns, top_left_index + corner_columns + 1]; + + patches.push(PatchData { + corner_indices, + position_bezier_net: coons_to_position_bezier_net(&patch.corners, &patch.edges), + }); + } + } + + Ok(Self { + patch_rows, + patch_columns, + patches, + corners, + color_bezier_nets: OnceLock::new(), + space, + interpolation, + }) + } + + /// Calculate the bicubic Bezier nets for every patch. + /// The color surface is derived from cubic Hermite interpolation to achieve C1-continuity of color between all patches. + fn init_color_bezier_nets(&self) -> Vec> { + let corner_columns = self.patch_columns + 1; + let corner_rows = self.patch_rows + 1; + + 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 + }; + + // Calculate the slope of the `curr_index` corner by finite difference method. The slope is derived from the linear distance from the previous/next corners. + let calculate_spatial_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let [prev_corner, curr_corner, next_corner] = [prev_index, curr_index, next_index].map(|index| &self.corners[index]); + let prev_distance = curr_corner.position.distance(prev_corner.position) as f32; + let next_distance = next_corner.position.distance(curr_corner.position) as f32; + let backward_diff = (prev_distance > f32::EPSILON).then(|| (curr_corner.color - prev_corner.color) / prev_distance); + let forward_diff = (next_distance > f32::EPSILON).then(|| (next_corner.color - curr_corner.color) / next_distance); + + match (backward_diff, forward_diff) { + (Some(backward), Some(forward)) => { + let backward_weight = 2. * next_distance + prev_distance; + let forward_weight = next_distance + 2. * prev_distance; + + // Prevent overshooting by using a zero slope at a local extremum. + Vec4::from_array(std::array::from_fn(|channel| { + if backward[channel] * forward[channel] <= 0. { + 0. + } else { + (backward_weight + forward_weight) / (backward_weight / backward[channel] + forward_weight / forward[channel]) + } + })) + } + (Some(backward), None) => backward, + (None, Some(forward)) => forward, + (None, None) => Vec4::ZERO, + } + }; + + let mut spatial_color_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_spatial_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_spatial_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + spatial_color_slopes.push([u, v]); + } + } + + let mut color_nets = vec![]; + for row in 0..self.patch_rows { + for column in 0..self.patch_columns { + let patch_index = row * self.patch_columns + column; + let patch = &self.patches[patch_index]; + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.position_bezier_net.corners(); + let top_length = top_left_pos.distance(top_right_pos) as f32; + let bottom_length = bottom_left_pos.distance(bottom_right_pos) as f32; + let left_length = top_left_pos.distance(bottom_left_pos) as f32; + let right_length = top_right_pos.distance(bottom_right_pos) as f32; + let corner_related_lengths = [[top_length, left_length], [top_length, right_length], [bottom_length, left_length], [bottom_length, right_length]]; + + let color_derivatives: [ColorDerivative; 4] = std::array::from_fn(|index| { + let corner_index = patch.corner_indices[index]; + let [u_slope, v_slope] = spatial_color_slopes[corner_index]; + let [u_length, v_length] = corner_related_lengths[index]; + ColorDerivative { + u: u_slope * u_length, + v: v_slope * v_length, + } + }); + let colors = patch.corner_indices.map(|corner_index| self.corners[corner_index].color); + color_nets.push(hermite_to_color_bezier_net(&colors, &color_derivatives)); + } + } + + color_nets + } + + fn color_bezier_nets(&self) -> &[BicubicBezierNet] { + self.color_bezier_nets.get_or_init(|| self.init_color_bezier_nets()) + } + + pub fn patch_dimension(&self) -> (i64, i64) { + (self.patch_rows as i64, self.patch_columns as i64) + } + + pub fn interpolation_method(&self) -> GradientInterpolation { + self.interpolation + } + + pub fn space(&self) -> GradientSpace { + self.space + } + + pub fn patches(&self) -> impl Iterator> { + (0..self.patches.len()).map(|index| MeshPatchEvaluator { mesh: self, index }) + } + + pub fn patch(&self, patch_index: usize) -> Option> { + if patch_index > self.patches.len() { + return None; + } + Some(MeshPatchEvaluator { mesh: self, index: patch_index }) + } +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +pub struct MeshPatchEvaluator<'a> { + mesh: &'a MeshGradientEvaluator, + index: usize, +} + +impl<'a> MeshPatchEvaluator<'a> { + pub fn index(&self) -> usize { + self.index + } + + pub fn colors(&self) -> [Vec4; 4] { + let corner_indices = self.mesh.patches[self.index].corner_indices; + corner_indices.map(|index| self.mesh.corners[index].color) + } + + pub fn position_bezier_net(&self) -> BicubicBezierNet { + self.mesh.patches[self.index].position_bezier_net + } + + pub fn color_bezier_net(&self) -> BicubicBezierNet { + self.mesh.color_bezier_nets()[self.index] + } + + /// Evaluates the raw interpolated color-space channels using the selected interpolation method. + fn evaluate_channels(&self, u: f32, v: f32) -> [f32; 4] { + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors(); + + match self.mesh.interpolation_method() { + GradientInterpolation::Stepped => top_left_color.to_array(), + GradientInterpolation::Linear => { + let top = top_left_color.lerp(top_right_color, u); + let bottom = bottom_left_color.lerp(bottom_right_color, u); + top.lerp(bottom, v).to_array() + } + GradientInterpolation::Smooth => { + let rows: [Vec4; 4] = array::from_fn(|row| self.evaluate_color_bezier_row(row, u).unwrap()); + let result = evaluate_cubic_bezier_bernstein(&rows, v); + result.to_array() + } + } + } + + /// Evaluates the interpolated color and returns gamma-sRGB channels for rendering. + pub fn evaluate_color(&self, u: f32, v: f32) -> [f32; 4] { + let channels = self.evaluate_channels(u, v); + if self.mesh.space == GradientSpace::RgbGamma { + channels + } else { + color_from_gradient_space_channels(channels, self.mesh.space).to_gamma_srgb_channels() + } + } + + /// Evaluates the shape control net as a bicubic tensor-product Bezier patch at UV, returning the corresponding position in mesh-local space. + pub fn evaluate_position(&self, u: f64, v: f64) -> DVec2 { + let u_interpolated = self.position_bezier_net().map(|control_point| evaluate_cubic_bezier_bernstein(&control_point, u)); + evaluate_cubic_bezier_bernstein(&u_interpolated, v) + } + + /// Returns [0,1] approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. + pub fn inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> DVec2 { + let (uv, _) = self.inverse_patch_position_impl(target_position, initial_uv); + uv.clamp(DVec2::ZERO, DVec2::ONE) + } + + /// Returns the unbounded UV when Newton's method converges, allowing neighboring positions to continue the same inverse branch. + pub fn try_inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> Option { + let (uv, converged) = self.inverse_patch_position_impl(target_position, initial_uv); + converged.then_some(uv) + } + + fn inverse_patch_position_impl(&self, target_position: DVec2, initial_uv: DVec2) -> (DVec2, bool) { + const MAX_ITERATION: usize = 16; + const POSITION_TOLERANCE: f64 = 1e-6; + const JACOBIAN_EPSILON: f64 = 1e-12; + const LINE_SEARCH_STEPS: usize = 8; + + let mut uv = initial_uv; + + for _ in 0..MAX_ITERATION { + let DVec2 { x: u, y: v } = uv; + // Check if the current uv position is already within the tolerance + let position = self.evaluate_position(u, v); + let error = position - target_position; + let error_squared = error.length_squared(); + + if !error_squared.is_finite() { + break; + } + + if error_squared <= POSITION_TOLERANCE * POSITION_TOLERANCE { + return (uv, true); + } + + // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error + let jacobian = position_jacobian(&self.position_bezier_net(), u, v); + let determinant = jacobian.determinant(); + if !determinant.is_finite() || determinant.abs() <= JACOBIAN_EPSILON { + break; + } + + let delta = jacobian.inverse() * error; + if !delta.is_finite() { + break; + } + + // Try progressively smaller Newton steps until the error decreases + let mut step = 1.; + let mut next_uv = None; + for _ in 0..LINE_SEARCH_STEPS { + let candidate = uv - delta * step; + let candidate_error_squared = self.evaluate_position(candidate.x, candidate.y).distance_squared(target_position); + + if candidate_error_squared.is_finite() && candidate_error_squared < error_squared { + next_uv = Some(candidate); + break; + } + + step *= 0.5; + } + + let Some(next_uv) = next_uv else { + break; + }; + uv = next_uv; + } + + let error_squared = self.evaluate_position(uv.x, uv.y).distance_squared(target_position); + (uv, error_squared.is_finite() && error_squared <= POSITION_TOLERANCE * POSITION_TOLERANCE) + } + + /// Evaluates one horizontal Bezier control row of a smooth patch. + pub fn evaluate_color_bezier_row(&self, row: usize, u: f32) -> Option { + if self.mesh.interpolation_method() != GradientInterpolation::Smooth { + return None; + }; + + let control_net = self.mesh.color_bezier_nets()[self.index].get(row)?; + Some(evaluate_cubic_bezier_bernstein(control_net, u)) + } +} + +// ================ +// Helper functions +// ================ + +/// Returns the affine that fits the mesh gradient geometry to the provided bounds. +pub fn initial_mesh_gradient_transform_for_bounding_box(bounds: [DVec2; 2]) -> DAffine2 { + let [min, max] = bounds; + let size = max - min; + DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) +} + +/// Helper to create initial handles. +fn line_to_cubic_bezier_handles(start: DVec2, end: DVec2) -> (Option, Option) { + (Some(start + (end - start) / 3.), Some(end + (start - end) / 3.)) +} + +/// Degree-elevate a path segment to a cubic Bézier while preserving its parameterization. +/// Unlike `PathSeg::to_cubic()`, this preserves the parameterization of line segments. +fn pathseg_to_cubic_bez(pathseg: PathSeg) -> CubicBez { + match pathseg { + PathSeg::Line(line) => { + let p0 = line.start(); + let p3 = line.end(); + CubicBez::new(p0, p0 + (p3 - p0) / 3., p3 + (p0 - p3) / 3., p3) + } + PathSeg::Quad(quad_bez) => quad_bez.raise(), + PathSeg::Cubic(cubic_bez) => cubic_bez, + } +} + +/// Evaluates a cubic Bezier curve at `time` using the Bernstein basis. +pub fn evaluate_cubic_bezier_bernstein + Add, T: Float>(control_points: &[C; 4], time: T) -> C { + let [p0, p1, p2, p3] = *control_points; + let one_minus_time: T = T::one() - time; + let three = T::one() + T::one() + T::one(); + p0 * one_minus_time.powi(3) + p1 * (three * time * one_minus_time.powi(2)) + p2 * (three * time.powi(2) * one_minus_time) + p3 * time.powi(3) +} + +/// Restates a Coons patch as the control net of the equivalent bicubic Bezier surface. +fn coons_to_position_bezier_net(corners: &[DVec2; 4], edges: &[PathSeg; 4]) -> BicubicBezierNet { + let cubic_bez_to_points_array = |bez: CubicBez| [bez.p0, bez.p1, bez.p2, bez.p3].map(point_to_dvec2); + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = corners; + let [top_control_points, bottom_control_points, left_control_points, right_control_points] = array::from_fn(|i| cubic_bez_to_points_array(pathseg_to_cubic_bez(edges[i]))); + + BicubicBezierNet(array::from_fn(|j| { + let v = j as f64 / 3.; + array::from_fn(|i| { + let u = i as f64 / 3.; + let left_weight = 1. - u; + let right_weight = u; + let top_weight = 1. - v; + let bottom_weight = v; + + let top_bottom_lerped_point = top_weight * top_control_points[i] + bottom_weight * bottom_control_points[i]; + let left_right_lerped_point = left_weight * left_control_points[j] + right_weight * right_control_points[j]; + let bilerped_corner_point = + top_weight * left_weight * top_left_pos + top_weight * right_weight * top_right_pos + bottom_weight * left_weight * bottom_left_pos + bottom_weight * right_weight * bottom_right_pos; + + top_bottom_lerped_point + left_right_lerped_point - bilerped_corner_point + }) + })) +} + +/// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. +fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[ColorDerivative; 4]) -> BicubicBezierNet { + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = *colors; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = *color_derivatives; + + let hermite_channels: [Mat4; 4] = std::array::from_fn(|channel| { + Mat4::from_cols( + Vec4::new(top_left_color[channel], top_left_color_slope.v[channel], bottom_left_color[channel], bottom_left_color_slope.v[channel]), + Vec4::new(top_left_color_slope.u[channel], 0., bottom_left_color_slope.u[channel], 0.), + Vec4::new( + top_right_color[channel], + top_right_color_slope.v[channel], + bottom_right_color[channel], + bottom_right_color_slope.v[channel], + ), + Vec4::new(top_right_color_slope.u[channel], 0., bottom_right_color_slope.u[channel], 0.), + ) + }); + + let hermite_to_bezier_axis = Mat4::from_cols(Vec4::new(1., 1., 0., 0.), Vec4::new(0., 1. / 3., 0., 0.), Vec4::new(0., 0., 1., 1.), Vec4::new(0., 0., -1. / 3., 0.)); + let hermite_to_bezier_axis_transpose = hermite_to_bezier_axis.transpose(); + + let points_mat = hermite_channels.map(|hermite| hermite_to_bezier_axis * hermite * hermite_to_bezier_axis_transpose); + + BicubicBezierNet(std::array::from_fn(|v| { + std::array::from_fn(|u| Vec4::new(points_mat[0].col(u)[v], points_mat[1].col(u)[v], points_mat[2].col(u)[v], points_mat[3].col(u)[v])) + })) +} + +/// Returns Jacobian matrix of the UV position in a single Coons patch. +fn position_jacobian(position_bezier_net: &BicubicBezierNet, u: f64, v: f64) -> DMat2 { + let evaluate_quadratic_bezier = |control_points: &[DVec2; 3], time: f64| { + let [p0, p1, p2] = control_points; + let one_minus_time = 1. - time; + one_minus_time.powi(2) * p0 + 2. * time * one_minus_time * p1 + time.powi(2) * p2 + }; + + let u_derivative_bezier_net: [[DVec2; 3]; 4] = array::from_fn(|j| array::from_fn(|i| 3. * (position_bezier_net[j][i + 1] - position_bezier_net[j][i]))); + let v_derivative_bezier_net: [[DVec2; 4]; 3] = array::from_fn(|j| array::from_fn(|i| 3. * (position_bezier_net[j + 1][i] - position_bezier_net[j][i]))); + + let u_derivative_control_points_over_v: [DVec2; 4] = array::from_fn(|i| evaluate_quadratic_bezier(&u_derivative_bezier_net[i], u)); + let v_derivative_control_points_over_v: [DVec2; 3] = array::from_fn(|i| evaluate_cubic_bezier_bernstein(&v_derivative_bezier_net[i], u)); + + DMat2::from_cols( + evaluate_cubic_bezier_bernstein(&u_derivative_control_points_over_v, v), + evaluate_quadratic_bezier(&v_derivative_control_points_over_v, v), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_position(actual: DVec2, expected: DVec2) { + assert!((actual - expected).length() < 1e-10, "expected {expected:?}, got {actual:?}"); + } + + fn point(position: DVec2) -> kurbo::Point { + kurbo::Point::new(position.x, position.y) + } + + fn line_edges([top_left, top_right, bottom_left, bottom_right]: [DVec2; 4]) -> [PathSeg; 4] { + [ + PathSeg::Line(kurbo::Line::new(point(top_left), point(top_right))), + PathSeg::Line(kurbo::Line::new(point(bottom_left), point(bottom_right))), + PathSeg::Line(kurbo::Line::new(point(top_left), point(bottom_left))), + PathSeg::Line(kurbo::Line::new(point(top_right), point(bottom_right))), + ] + } + + fn patch_evaluator(corners: [DVec2; 4], edges: [PathSeg; 4]) -> MeshPatchEvaluator { + let mut mesh = MeshGradient::from_positions(&corners, 2, 2).unwrap(); + let edge_ids = [ + *mesh.horizontal_edges.get(0, 0).unwrap(), + *mesh.horizontal_edges.get(1, 0).unwrap(), + *mesh.vertical_edges.get(0, 0).unwrap(), + *mesh.vertical_edges.get(0, 1).unwrap(), + ]; + + for (edge_id, edge) in edge_ids.into_iter().zip(edges) { + let edge = pathseg_to_cubic_bez(edge); + mesh.set_edge_handles( + edge_id, + BezierHandles::Cubic { + handle_start: point_to_dvec2(edge.p1), + handle_end: point_to_dvec2(edge.p2), + }, + ) + .unwrap(); + } + + mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap().patch(0).unwrap().clone() + } + + fn mesh_with_corner_colors(mut color: impl FnMut(usize) -> Color) -> MeshGradient { + let mut mesh = MeshGradient::default(); + for corner_index in 0..mesh.size() { + mesh.set_corner_color(corner_index, color(corner_index)).unwrap(); + } + mesh + } + + fn single_patch_mesh(colors: [Color; 4]) -> MeshGradient { + let positions = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let mut mesh = MeshGradient::from_positions(&positions, 2, 2).unwrap(); + for (corner_index, color) in colors.into_iter().enumerate() { + mesh.set_corner_color(corner_index, color).unwrap(); + } + mesh + } + + fn curved_patch_evaluator() -> ([DVec2; 4], [PathSeg; 4], MeshPatchEvaluator) { + let corners = [DVec2::new(0., 0.), DVec2::new(2., 0.), DVec2::new(0., 2.), DVec2::new(2., 2.)]; + let [top_left, top_right, bottom_left, bottom_right] = corners.map(point); + let edges = [ + PathSeg::Cubic(kurbo::CubicBez::new(top_left, kurbo::Point::new(0.5, -0.5), kurbo::Point::new(1.5, 0.5), top_right)), + PathSeg::Cubic(kurbo::CubicBez::new(bottom_left, kurbo::Point::new(0.5, 2.5), kurbo::Point::new(1.5, 1.5), bottom_right)), + PathSeg::Cubic(kurbo::CubicBez::new(top_left, kurbo::Point::new(-0.4, 0.5), kurbo::Point::new(0.4, 1.5), bottom_left)), + PathSeg::Cubic(kurbo::CubicBez::new(top_right, kurbo::Point::new(2.4, 0.5), kurbo::Point::new(1.6, 1.5), bottom_right)), + ]; + (corners, edges, patch_evaluator(corners, edges)) + } + + #[test] + fn evaluate_color_reproduces_an_affine_color_field() { + let base = Vec4::new(0.1, 0.2, 0.3, 0.4); + let u_delta = Vec4::new(0.2, 0.1, -0.1, 0.2); + let v_delta = Vec4::new(0.3, -0.1, 0.2, 0.1); + let colors = [base, base + u_delta, base + v_delta, base + u_delta + v_delta]; + let color_slopes = [ColorDerivative { u: u_delta, v: v_delta }; 4]; + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let mut evaluator = patch_evaluator(corners, line_edges(corners)); + evaluator.colors = colors; + evaluator.interpolation = MeshPatchInterpolation::Smooth { + color_derivatives: color_slopes, + color_bezier_net: Box::new(hermite_to_color_bezier_net(&colors, &color_slopes)), + }; + + for [u, v] in [[0., 0.], [0.37, 0.61], [1., 1.]] { + let actual = Vec4::from_array(evaluator.evaluate_color(u, v)); + let expected = base + u_delta * u + v_delta * v; + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?}"); + } + } + + #[test] + fn stepped_interpolation_uses_the_top_left_patch_color() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::RgbGamma, GradientInterpolation::Stepped).unwrap(); + let patch = evaluator.patch(0).unwrap(); + let expected = Vec4::from_array(colors[0].to_gamma_srgb_channels()); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + + #[test] + fn linear_interpolation_bilinearly_blends_the_patch_colors() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap(); + let patch = evaluator.patch(0).unwrap(); + let [top_left, top_right, bottom_left, bottom_right] = colors.map(|color| Vec4::from_array(color.to_gamma_srgb_channels())); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let expected = top_left.lerp(top_right, u).lerp(bottom_left.lerp(bottom_right, u), v); + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + + #[test] + fn linear_oklab_interpolation_bilinearly_blends_oklab_channels() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::OkLab, GradientInterpolation::Linear).unwrap(); + let patch = evaluator.patch(0).unwrap(); + let [top_left, top_right, bottom_left, bottom_right] = colors.map(|color| Vec4::from_array(gradient_space_channels(color, GradientSpace::OkLab))); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let oklab = top_left.lerp(top_right, u).lerp(bottom_left.lerp(bottom_right, u), v); + let expected = Vec4::from_array(color_from_gradient_space_channels(oklab.to_array(), GradientSpace::OkLab).to_gamma_srgb_channels()); + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + + #[test] + fn rectangular_spaces_keep_their_own_channels() { + let colors = [Color::BLACK, Color::WHITE, Color::from_rgbf32_unchecked(0.85, 0.05, 0.4)]; + let mesh = mesh_with_corner_colors(|corner_index| colors[corner_index % colors.len()]); + + for space in [GradientSpace::RgbGamma, GradientSpace::RgbLinear, GradientSpace::OkLab, GradientSpace::Lab] { + let evaluator = mesh.evaluator(space, GradientInterpolation::Smooth).unwrap(); + let patch = evaluator.patch(0).unwrap(); + let expected = Vec4::from_array(gradient_space_channels(colors[0], space)); + + assert!((patch.colors[0] - expected).abs().max_element() < 1e-6, "{space:?} must store its corner channels untouched"); + } + } + + #[test] + fn evaluate_position_reproduces_patch_boundaries() { + let (_, edges, evaluator) = curved_patch_evaluator(); + + for t in [0., 0.25, 0.5, 0.75, 1.] { + assert_position(evaluator.evaluate_position(t, 0.), point_to_dvec2(edges[0].eval(t))); + assert_position(evaluator.evaluate_position(t, 1.), point_to_dvec2(edges[1].eval(t))); + assert_position(evaluator.evaluate_position(0., t), point_to_dvec2(edges[2].eval(t))); + assert_position(evaluator.evaluate_position(1., t), point_to_dvec2(edges[3].eval(t))); + } + } + + #[test] + fn position_jacobian_matches_affine_patch() { + let transform = DAffine2::from_cols(DVec2::new(3., 0.5), DVec2::new(-0.25, 2.), DVec2::new(4., -3.)); + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|corner| transform.transform_point2(corner)); + let edges = line_edges(corners); + + for [u, v] in [[0., 0.], [0.25, 0.75], [0.5, 0.5], [1., 1.]] { + let position_bezier_net = coons_to_position_bezier_net(&corners, &edges); + let jacobian = position_jacobian(&position_bezier_net, u, v); + assert_position(jacobian.x_axis, transform.matrix2.x_axis); + assert_position(jacobian.y_axis, transform.matrix2.y_axis); + } + } + + #[test] + fn bounding_box_uses_mesh_geometry() { + let bounds = core_types::bounds::BoundingBox::bounding_box(&MeshGradient::default(), DAffine2::IDENTITY, false); + assert_eq!(bounds, core_types::bounds::RenderBoundingBox::Rectangle([DVec2::ZERO, DVec2::ONE])); + } + + #[test] + fn position_jacobian_matches_numerical_derivative_for_curved_patch() { + let (corners, edges, evaluator) = curved_patch_evaluator(); + let (u, v, step) = (0.37, 0.61, 1e-6); + + let numerical_u = (evaluator.evaluate_position(u + step, v) - evaluator.evaluate_position(u - step, v)) / (2. * step); + let numerical_v = (evaluator.evaluate_position(u, v + step) - evaluator.evaluate_position(u, v - step)) / (2. * step); + let jacobian = position_jacobian(&coons_to_position_bezier_net(&corners, &edges), u, v); + + assert!((jacobian.x_axis - numerical_u).length() < 1e-8, "expected {:?}, got {:?}", numerical_u, jacobian.x_axis); + assert!((jacobian.y_axis - numerical_v).length() < 1e-8, "expected {:?}, got {:?}", numerical_v, jacobian.y_axis); + } + + #[test] + fn inverse_patch_position_recovers_curved_patch_uv() { + let (_, _, evaluator) = curved_patch_evaluator(); + let expected = DVec2::new(0.37, 0.61); + let target = evaluator.evaluate_position(expected.x, expected.y); + let actual = evaluator.inverse_patch_position(target, DVec2::splat(0.5)); + + assert!((actual - expected).length() < 1e-6, "expected {expected:?}, got {actual:?}"); + } + + #[test] + fn inverse_patch_position_clamps_to_patch_uv_bounds() { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + let actual = evaluator.inverse_patch_position(DVec2::new(1.5, 0.4), DVec2::splat(0.5)); + + assert_position(actual, DVec2::new(1., 0.4)); + } + + #[test] + fn try_inverse_patch_position_returns_unbounded_uv() { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + let actual = evaluator.try_inverse_patch_position(DVec2::new(1.5, 0.4), DVec2::splat(0.5)).unwrap(); + + assert_position(actual, DVec2::new(1.5, 0.4)); + } + + #[test] + fn try_inverse_patch_position_reports_singular_patch() { + let corners = [DVec2::ZERO; 4]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + + assert!(evaluator.try_inverse_patch_position(DVec2::ONE, DVec2::splat(0.5)).is_none()); + } + + #[test] + fn inserting_mesh_grid_lines_preserves_row_major_topology() { + let mut mesh = MeshGradient::default(); + let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.25).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 4]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 3]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 4]); + let expected_x = [0., 0.125, 0.5, 1.]; + for row in 0..mesh.corner_points.rows { + for (column, &x) in expected_x.iter().enumerate() { + let position = mesh.mesh_geometry.point_domain.position_from_id(*mesh.corner_points.get(row, column).unwrap()).unwrap(); + assert_position(position, DVec2::new(x, row as f64 / 2.)); + } + } + + let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [4, 4]); + assert_eq!(mesh.horizontal_edges.dimensions(), [4, 3]); + assert_eq!(mesh.vertical_edges.dimensions(), [3, 4]); + let expected_y = [0., 0.25, 0.5, 1.]; + for (row, &y) in expected_y.iter().enumerate() { + for (column, &x) in expected_x.iter().enumerate() { + let position = mesh.mesh_geometry.point_domain.position_from_id(*mesh.corner_points.get(row, column).unwrap()).unwrap(); + assert_position(position, DVec2::new(x, y)); + } + } + + for row in 0..mesh.corner_points.rows - 1 { + for column in 0..mesh.corner_points.columns - 1 { + let patch = mesh.patch(row, column).unwrap(); + assert_position(patch.corners[0], DVec2::new(expected_x[column], expected_y[row])); + assert_position(patch.corners[3], DVec2::new(expected_x[column + 1], expected_y[row + 1])); + } + } + } + + #[test] + fn removing_mesh_edges_removes_their_interior_grid_lines() { + let mut mesh = MeshGradient::default(); + let expected_positions: Vec<_> = mesh.corners().map(|corner| corner.position).collect(); + let expected_colors: Vec<_> = mesh.corners().map(|corner| corner.color).collect(); + + let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.25).unwrap(); + let inserted_vertical_edge = *mesh.vertical_edges.get(0, 1).unwrap(); + mesh.remove_edge(inserted_vertical_edge).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 3]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 2]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 3]); + assert_eq!(mesh.corners().map(|corner| corner.position).collect::>(), expected_positions); + assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); + + let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); + let inserted_horizontal_edge = *mesh.horizontal_edges.get(1, 0).unwrap(); + mesh.remove_edge(inserted_horizontal_edge).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 3]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 2]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 3]); + assert_eq!(mesh.corners().map(|corner| corner.position).collect::>(), expected_positions); + assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); + assert_eq!(mesh.patches().collect::>>().unwrap().len(), 4); + + let boundary_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + assert_eq!(mesh.remove_edge(boundary_edge), None); + } + + #[test] + fn removing_an_inserted_grid_line_restores_the_edge_curve() { + let mut mesh = MeshGradient::default(); + let edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + // Symmetric about the edge's midpoint, so an even split leaves the two halves with equal chords + mesh.set_edge_handles( + edge, + BezierHandles::Cubic { + handle_start: DVec2::new(0.125, 0.2), + handle_end: DVec2::new(0.375, 0.2), + }, + ) + .unwrap(); + let before = mesh.mesh_geometry.segment_from_id(edge).unwrap().to_cubic(); + + mesh.insert_grid_line(edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); + let inserted = *mesh.vertical_edges.get(0, 1).unwrap(); + mesh.remove_edge(inserted).unwrap(); + + let merged = mesh.mesh_geometry.segment_from_id(*mesh.horizontal_edges.get(0, 0).unwrap()).unwrap().to_cubic(); + for (actual, expected) in [(merged.p0, before.p0), (merged.p1, before.p1), (merged.p2, before.p2), (merged.p3, before.p3)] { + assert_position(point_to_dvec2(actual), point_to_dvec2(expected)); + } + } +} diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index c74992c75b9..5e40d6c0f78 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -708,13 +708,15 @@ fn parse_context_feature_idents(ty: &Type) -> Vec { | "ExtractPosition" | "ExtractIndex" | "ExtractVarArgs" + | "ExtractPaintRenderParams" | "InjectFootprint" | "InjectRealTime" | "InjectAnimationTime" | "InjectPointerPosition" | "InjectPosition" | "InjectIndex" - | "InjectVarArgs" => { + | "InjectVarArgs" + | "InjectPaintRenderParams" => { features.push(segment.ident.clone()); } // Skip Modify* traits as they don't affect usage tracking diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index 024473fafbf..4775dc12216 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -4,7 +4,7 @@ use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; use core_types::transform::Footprint; use core_types::{Color, OwnedContextImpl}; use glam::{DAffine2, DVec2}; -use graphic_types::vector_types::Gradient; +use graphic_types::vector_types::{Gradient, MeshGradient}; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -31,6 +31,7 @@ async fn context_modification( Context -> Item, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> Item, Context -> List, @@ -43,6 +44,7 @@ async fn context_modification( Context -> List, Context -> List, Context -> List, + Context -> List, Context -> ListDyn, )] value: impl Node, Output = T>, diff --git a/node-graph/nodes/gradient/Cargo.toml b/node-graph/nodes/gradient/Cargo.toml new file mode 100644 index 00000000000..5d355446064 --- /dev/null +++ b/node-graph/nodes/gradient/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "gradient-nodes" +version = "0.1.0" +edition = "2024" +description = "Mesh gradient rendering nodes for Graphene" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[features] +default = ["serde"] +serde = ["dep:serde", "core-types/serde", "raster-types/serde"] + +[dependencies] +# Local dependencies +dyn-any = { workspace = true } +brush-types = { workspace = true } +core-types = { workspace = true } +graphene-hash = { workspace = true } +graphic-types = { workspace = true } +vector-types = { workspace = true } +raster-types = { workspace = true, features = ["wgpu"] } +wgpu-executor = { workspace = true } +node-macro = { workspace = true } + +# Workspace dependencies +log = { workspace = true } +glam = { workspace = true } +half = { workspace = true } +bytemuck = { workspace = true } +wgpu = { workspace = true } + +# Optional workspace dependencies +serde = { workspace = true, optional = true, features = ["derive"] } + +[dev-dependencies] +# Workspace dependencies +tokio = { workspace = true } diff --git a/node-graph/nodes/gradient/src/lib.rs b/node-graph/nodes/gradient/src/lib.rs new file mode 100644 index 00000000000..fad905fe607 --- /dev/null +++ b/node-graph/nodes/gradient/src/lib.rs @@ -0,0 +1 @@ +pub mod mesh_gradient; diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs new file mode 100644 index 00000000000..0f7a128435a --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -0,0 +1,162 @@ +mod pipeline; +mod tessellate; + +use core_types::{ + ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_TRANSFORM, Ctx, ExtractFootprint, ExtractPaintRenderParams, + bounds::{BoundingBox, RenderBoundingBox}, + list::{ATTR_TEXTURE, Item}, + math::bbox::AxisAlignedBbox, + transform::Footprint, +}; +use glam::{DAffine2, DVec2, UVec2}; +use vector_types::{GradientInterpolation, GradientSpace, MeshGradient, gradient::MeshGradientEvaluator}; +use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; + +use crate::mesh_gradient::{ + pipeline::{MeshGradientPipeline, MeshGradientPipelineArgs}, + tessellate::{MeshGradientTessellator, Metadata}, +}; + +const MAX_RESOLUTION: u32 = 8192; + +/// Constructs a mesh gradient value composed of a grid of patches defined by colored corners and curved boundary segments. +#[node_macro::node(category("Value"))] +pub async fn mesh_gradient_value<'a: 'n>( + ctx: impl Ctx + ExtractFootprint + ExtractPaintRenderParams, + mesh_gradient: Item, + /// Draw triangle outlines for debugging. + #[name("Debug Outline")] + #[default(false)] + debug: Item, + #[scope(mesh_gradient_pipeline::IDENTIFIER)] pipeline: Item, +) -> Item { + // FIXME: debug + let debug = *debug.element(); + let pipeline = pipeline.into_element(); + + let mut mesh_gradient_item = mesh_gradient; + let mesh_gradient = mesh_gradient_item.element(); + + let interpolation_space: GradientSpace = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); + let interpolation_method: GradientInterpolation = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); + + let Some(evaluator) = mesh_gradient.evaluator(interpolation_space, interpolation_method).ok() else { + return Item::default(); + }; + + let mesh_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); + let mesh_to_output = ctx.footprint().transform * mesh_to_target; + let Some((texture_to_output, texture_size)) = calc_texture_to_output(mesh_gradient, mesh_to_output, *ctx.footprint()) else { + return Item::default(); + }; + if !texture_to_output.matrix2.determinant().recip().is_finite() { + return Item::default(); + } + let mesh_to_texture = texture_to_output.inverse() * mesh_to_output; + // Need to offset the paint target's transform to prevent duplicated application + let texture_transform = ctx.footprint().transform.inverse() * texture_to_output; + + let tessellator = MeshGradientTessellator::new(&evaluator, mesh_to_texture, mesh_to_output); + + let (vertices, indices) = match tessellator.tessellate() { + Ok(result) => result, + Err(error) => { + log::error!("Failed to tessellate mesh gradient: {error:?}"); + return Item::default(); + } + }; + + if vertices.is_empty() { + return Item::default(); + } + + let color_data = pack_color_data(&evaluator, interpolation_method); + + let Some(interpolation_space) = try_interpolation_space_to_u32(interpolation_space) else { + return Item::default(); + }; + let interpolation_method = interpolation_method_to_u32(interpolation_method); + + let args = MeshGradientPipelineArgs { + output_size: texture_size, + vertices: &vertices, + indices: &indices, + color_data: color_data.as_slice(), + metadata: &Metadata { + patch_count: evaluator.patches().count() as u32, + interpolation_space, + interpolation_method, + }, + debug, + }; + + let Some(texture) = pipeline.run::(&args).await else { + return Item::default(); + }; + let texture_item = Item::from(texture).with_attribute(ATTR_TRANSFORM, texture_transform); + + mesh_gradient_item.set_attribute(ATTR_TRANSFORM, mesh_to_target); + mesh_gradient_item.set_attribute(ATTR_TEXTURE, Some(texture_item)); + + mesh_gradient_item +} + +#[node_macro::node(category(""), inject_scope)] +async fn mesh_gradient_pipeline<'a: 'n>( + _ctx: impl Ctx, + #[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: Item<&'a WgpuExecutor>, + #[data] pipeline: WgpuPipelineCache, +) -> Item { + executor.into_element().pipeline_init::(pipeline); + Item::new_from_element(pipeline.clone()) +} + +fn calc_texture_to_output(mesh_gradient: &MeshGradient, mesh_to_output: DAffine2, footprint: Footprint) -> Option<(DAffine2, UVec2)> { + let mesh_bbox = mesh_gradient.bounding_box(mesh_to_output, false); + let mesh_aabb = match mesh_bbox { + RenderBoundingBox::Rectangle([start, end]) => AxisAlignedBbox::from((start, end)), + RenderBoundingBox::None | RenderBoundingBox::Infinite => return None, + }; + let output_aabb = AxisAlignedBbox::from((DVec2::ZERO, footprint.resolution.as_dvec2())); + let mut crop_aabb = mesh_aabb.intersect(&output_aabb); + + // Cast the texture size to integer by expanding the size + crop_aabb.start = crop_aabb.start.floor(); + crop_aabb.end = crop_aabb.end.ceil(); + + let crop_size = crop_aabb.size(); + if crop_size.x <= 0. || crop_size.y <= 0. { + return None; + } + let texture_to_output = DAffine2::from_scale_angle_translation(crop_size, 0., crop_aabb.start); + let texture_scale = ((MAX_RESOLUTION as f64) / crop_size.x.max(crop_size.y)).min(1.); + let texture_size = (crop_size * texture_scale).ceil().max(DVec2::ONE).min(DVec2::splat(MAX_RESOLUTION as f64)).as_uvec2(); + + Some((texture_to_output, texture_size)) +} + +pub(crate) fn try_interpolation_space_to_u32(space: GradientSpace) -> Option { + match space { + GradientSpace::RgbGamma => Some(0), + GradientSpace::RgbLinear => Some(1), + GradientSpace::OkLab => Some(2), + GradientSpace::Lab => Some(3), + _ => None, + } +} + +pub(crate) fn interpolation_method_to_u32(method: GradientInterpolation) -> u32 { + match method { + GradientInterpolation::Stepped => 0, + GradientInterpolation::Linear => 1, + GradientInterpolation::Smooth => 2, + } +} + +pub(crate) fn pack_color_data(evaluator: &MeshGradientEvaluator, interpolation_method: GradientInterpolation) -> Vec<[f32; 4]> { + match interpolation_method { + GradientInterpolation::Stepped => evaluator.patches().map(|patch| patch.colors()[0].to_array()).collect::>(), + GradientInterpolation::Linear => evaluator.patches().flat_map(|patch| patch.colors().map(|color| color.to_array())).collect::>(), + GradientInterpolation::Smooth => evaluator.patches().flat_map(|patch| patch.color_bezier_net().to_array()).collect::>(), + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs new file mode 100644 index 00000000000..c8ab8e3b905 --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs @@ -0,0 +1,294 @@ +use glam::UVec2; +use raster_types::Texture; +use wgpu_executor::{AsyncWgpuPipeline, WgpuExecutor}; + +use crate::mesh_gradient::tessellate::{MeshVertex, Metadata}; + +pub struct MeshGradientPipeline { + renderer: Renderer, +} + +pub struct MeshGradientPipelineArgs<'a> { + pub vertices: &'a [MeshVertex], + pub indices: &'a [u32], + pub output_size: UVec2, + pub color_data: &'a [[f32; 4]], + pub metadata: &'a Metadata, + pub debug: bool, +} + +impl AsyncWgpuPipeline for MeshGradientPipeline { + type Args<'a> = MeshGradientPipelineArgs<'a>; + type Out = Option; + + fn create(executor: &wgpu_executor::WgpuExecutor) -> Self { + let device = &executor.context().device; + Self { renderer: Renderer::new(device) } + } + + async fn run<'a>(&'a self, executor: &'a wgpu_executor::WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out { + self.renderer.render(executor, args) + } +} + +struct Renderer { + render_pipeline: wgpu::RenderPipeline, + debug_outline_pipeline: wgpu::RenderPipeline, // FIXME: only for debug + color_data_layout: wgpu::BindGroupLayout, +} + +impl Renderer { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("render.wgsl")); + + let data_buffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mesh_gradient_data_buffer_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("mesh_gradient_renderer_pipeline_layout"), + bind_group_layouts: &[Some(&data_buffer_layout)], + immediate_size: 0, + }); + + let vertex_buffer_layout = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![ + 0 => Uint32, + 1 => Float32x2, + 2 => Float32x2 + ], + }; + + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh_gradient_renderer_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + // FIXME: remove clone before commit + buffers: &[vertex_buffer_layout.clone()], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + // FIXME: only for debug + let debug_outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh_gradient_renderer_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[vertex_buffer_layout], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_debug_outline"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + Self { + render_pipeline, + debug_outline_pipeline, + color_data_layout: data_buffer_layout, + } + } + + fn render(&self, executor: &WgpuExecutor, args: &MeshGradientPipelineArgs) -> Option { + let mut encoder = executor.context().device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("mesh_gradient_renderer_encoder"), + }); + + if args.output_size.x == 0 || args.output_size.y == 0 { + return None; + } + + let texture = executor.request_texture(args.output_size); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let vertex_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_vertex_buffer"), + contents: bytemuck::cast_slice(args.vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + let index_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_index_buffer"), + contents: bytemuck::cast_slice(args.indices), + usage: wgpu::BufferUsages::INDEX, + }); + + let color_data_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_color_data_buffer"), + contents: bytemuck::cast_slice(args.color_data), + usage: wgpu::BufferUsages::STORAGE, + }); + + let metadata_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_metadata_buffer"), + contents: bytemuck::cast_slice(&[*args.metadata]), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let depth_texture = executor.context().device.create_texture(&wgpu::TextureDescriptor { + label: Some("mesh_gradient_depth_texture"), + size: wgpu::Extent3d { + width: args.output_size.x, + height: args.output_size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let color_data_bind_group = executor.context().device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mesh_gradient_color_data_bind_group"), + layout: &self.color_data_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: color_data_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: metadata_buffer.as_entire_binding(), + }, + ], + }); + + // FIXME: debug + let line_indices = args + .indices + .chunks_exact(3) + .flat_map(|triangle| { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]]; + [a, b, b, c, c, a] + }) + .collect::>(); + + let line_index_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_debug_line_index_buffer"), + contents: bytemuck::cast_slice(&line_indices), + usage: wgpu::BufferUsages::INDEX, + }); + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("mesh_gradient_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &texture_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + render_pass.set_pipeline(&self.render_pipeline); + render_pass.set_bind_group(0, &color_data_bind_group, &[]); + render_pass.set_vertex_buffer(0, (*vertex_buffer).slice(..)); + render_pass.set_index_buffer((*index_buffer).slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..args.indices.len() as u32, 0, 0..1); + + // FIXME: only for debug + if args.debug { + render_pass.set_pipeline(&self.debug_outline_pipeline); + render_pass.set_index_buffer((*line_index_buffer).slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..line_indices.len() as u32, 0, 0..1); + } + } + + let command_buffer = encoder.finish(); + executor.context().queue.submit([command_buffer]); + + Some(texture) + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl new file mode 100644 index 00000000000..a6496cefcfb --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl @@ -0,0 +1,214 @@ +struct Metadata { + patch_count: u32, + interpolation_space: u32, + interpolation_method: u32, +} + +struct VertexInput { + @location(0) @interpolate(flat) patch_index: u32, + @location(1) uv: vec2, + @location(2) position: vec2, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) patch_index: u32, + @location(1) uv: vec2, +}; + +@group(0) @binding(0) +var color_data: array>; +@group(0) @binding(1) +var metadata: Metadata; + +// ======= +// Shaders +// ======= + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + let clip_position = vec2( + input.position.x * 2.0 - 1.0, + 1.0 - input.position.y * 2.0 + ); + + // Following PostScript's priority order (from higher): Patch index -> Local v -> Local u + let local_priority = (input.uv.y * 1000 + input.uv.x) / 1002; + let z_position = (f32(input.patch_index) + local_priority) / f32(metadata.patch_count); + output.position = vec4(clip_position, z_position, 1.0); + output.patch_index = input.patch_index; + output.uv = input.uv; + + return output; +}; + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let u = input.uv.x; + let v = input.uv.y; + let patch_index = input.patch_index; + + let color_in_selected_space = evaluate_channels(patch_index, u, v); + return convert_to_gamma_srgb(color_in_selected_space); +}; + +// FIXME: only for debug +@fragment +fn fs_debug_outline() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 1.0); +}; + +// ================ +// Color evaluators +// ================ + +fn evaluate_channels(patch_index: u32, u: f32, v: f32) -> vec4 { + switch metadata.interpolation_method { + // Stepped + case 0: { + return color_data[patch_index]; + } + // Linear + case 1: { + let base_index = patch_index * 4; + return evaluate_bilinear_color_from_buffer(base_index, u, v); + } + // Bicubic + case 2: { + let base_index = patch_index * 16; + return evaluate_bicubic_bezier_color_from_buffer(base_index, u, v); + } + default { + return color_data[patch_index]; + } + } +}; + +fn evaluate_bilinear_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let top = mix(color_data[base_index], color_data[base_index + 1], u); + let bottom = mix(color_data[base_index + 2], color_data[base_index + 3], u); + return mix(top, bottom, v); +}; + +fn evaluate_cubic_bezier_color_from_buffer(base_index: u32, time: f32) -> vec4 { + let p0 = color_data[base_index]; + let p1 = color_data[base_index + 1]; + let p2 = color_data[base_index + 2]; + let p3 = color_data[base_index + 3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; +}; + +fn evaluate_cubic_bezier_color(control_points: array, 4>, time: f32) -> vec4 { + let p0 = control_points[0]; + let p1 = control_points[1]; + let p2 = control_points[2]; + let p3 = control_points[3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; +}; + +fn evaluate_bicubic_bezier_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let row0 = evaluate_cubic_bezier_color_from_buffer(base_index, u); + let row1 = evaluate_cubic_bezier_color_from_buffer(base_index + 4, u); + let row2 = evaluate_cubic_bezier_color_from_buffer(base_index + 8, u); + let row3 = evaluate_cubic_bezier_color_from_buffer(base_index + 12, u); + return evaluate_cubic_bezier_color(array(row0, row1, row2, row3), v); +}; + +// ====================== +// Color space converters +// ====================== + +fn convert_to_gamma_srgb(color: vec4) -> vec4 { + var linear_srgb: vec3; + switch metadata.interpolation_space { + // Gamma sRGB + case 0: { + return color; + } + // Linear sRGB + case 1: { + linear_srgb = color.rgb; + } + // OKLab + case 2: { + linear_srgb = oklab_to_linear_srgb(color.rgb); + } + // Lab + case 3: { + linear_srgb = lab_to_linear_srgb(color.rgb); + } + default: { + return color; + } + } + return vec4(linear_srgb_to_gamma_srgb(linear_srgb), color.a); +} + +// The following color-conversion functions are adapted from +// color 0.3.3's colorspace.rs: +// https://github.com/linebender/color +// +// Copyright 2024 the Color Authors. +// Licensed under Apache-2.0. +// Modifications: Translated from Rust to WGSL and adapted for Graphite. + +const OKLAB_LAB_TO_LMS = mat3x3( + vec3(1.0, 1.0, 1.0), + vec3(0.39633778, -0.105561346, -0.08948418), + vec3(0.21580376, -0.06385417, -1.2914855), +); + +const OKLAB_LMS_TO_SRGB = mat3x3( + vec3(4.0767417, -1.268438, -0.0041960863), + vec3(-3.3077116, 2.6097574, -0.7034186), + vec3(0.23096994, -0.34131938, 1.7076147), +); + +const LAB_XYZ_TO_SRGB = mat3x3( + vec3(3.0222337, -0.94384825, 0.06938627), + vec3(-1.617386, 1.9162544, -0.22897676), + vec3(-0.40484765, 0.027593868, 1.1595905), +); + +const LAB_KAPPA: f32 = 24389.0 / 27.0; +const LAB_EPSILON_CBRT: f32 = 0.20689656; + +fn oklab_to_linear_srgb(src: vec3) -> vec3 { + var lms = OKLAB_LAB_TO_LMS * src; + lms = lms * lms * lms; + return OKLAB_LMS_TO_SRGB * lms; +}; + +fn lab_to_linear_srgb(src: vec3) -> vec3 { + let f1 = src.x * (1.0 / 116.0) + (16.0 / 116.0); + let f0 = src.y * (1.0 / 500.0) + f1; + let f2 = f1 - src.z * (1.0 / 200.0); + let f = vec3(f0, f1, f2); + let xyz = select( + (116.0 / LAB_KAPPA) * f - vec3(16.0 / LAB_KAPPA), + f * f * f, + f > vec3(LAB_EPSILON_CBRT), + ); + return LAB_XYZ_TO_SRGB * xyz; +} + +fn linear_srgb_to_gamma_srgb(src: vec3) -> vec3 { + return vec3( + lin_to_srgb(src.x), + lin_to_srgb(src.y), + lin_to_srgb(src.z), + ); +} + +fn lin_to_srgb(x: f32) -> f32 { + if abs(x) <= 0.0031308 { + return x * 12.92; + } else { + let magnitude = 1.055 * pow(abs(x), 1.0 / 2.4) - 0.055; + return select(abs(magnitude), -abs(magnitude), x < 0.0); + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs new file mode 100644 index 00000000000..feaa1f0b2b4 --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs @@ -0,0 +1,521 @@ +use core::fmt; +use std::{ + array, + cmp::Ordering, + collections::{HashMap, VecDeque}, +}; + +use glam::{DAffine2, DVec2}; +use vector_types::{ + gradient::MeshGradientEvaluator, + mesh_gradient::{BicubicBezierNet, evaluate_cubic_bezier_bernstein}, +}; + +/// Maximum allowed geometry approximation error in viewport pixels. +const MESH_POSITION_DEVIATION_TOLERANCE: PositionDeviationBound = PositionDeviationBound(2.); + +const MAX_SUBDIVISION_DEPTH: u32 = 31; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct Metadata { + /// Total number of patches. + pub patch_count: u32, + /// 0 => gamma sRGB, 1 => linear sRGB, 2 => OKLab, 3 => Lab + pub interpolation_space: u32, + /// 0 => Stepped, 1 => Linear, 2 => Smooth + pub interpolation_method: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct MeshVertex { + pub patch_index: u32, + pub uv: [f32; 2], + pub position: [f32; 2], +} + +pub(super) struct MeshGradientTessellator<'a> { + evaluator: &'a MeshGradientEvaluator, + mesh_to_texture: DAffine2, + mesh_to_output: DAffine2, +} + +#[derive(Debug)] +pub(super) enum MeshGradientTessellatorError { + Subpatches, +} + +impl<'a> MeshGradientTessellator<'a> { + pub(super) fn new(evaluator: &'a MeshGradientEvaluator, mesh_to_texture: DAffine2, mesh_to_output: DAffine2) -> Self { + Self { + evaluator, + mesh_to_texture, + mesh_to_output, + } + } + + fn should_cull(&self, control_net: &BicubicBezierNet) -> bool { + let [bbox_min, bbox_max] = control_net.control_net_bounds(self.mesh_to_texture); + !(bbox_min.x <= 1. && 0. <= bbox_max.x && bbox_min.y <= 1. && 0. <= bbox_max.y) + } + + pub(super) fn tessellate(&self) -> Result<(Vec, Vec), MeshGradientTessellatorError> { + const RECT_TO_TRIANGLE_INDICES: [u32; 6] = [0, 1, 3, 1, 3, 2]; + + #[derive(Copy, Clone)] + struct BoundaryVertex { + uv: DVec2, + position: DVec2, + } + + let subpatches = self.subdivide_mesh_adaptive()?; + + let mut vertices = vec![]; + let mut indices = vec![]; + for subpatch in subpatches { + let patch_index = subpatch.patch_index as u32; + let base_vertex_index = vertices.len() as u32; + let [uv_min, uv_max] = subpatch.uv_bounds; + + // [top left, top right, bottom right, bottom left] + let corner_uvs_clockwise = [uv_min, DVec2::new(uv_max.x, uv_min.y), uv_max, DVec2::new(uv_min.x, uv_max.y)]; + let corner_vertices: [BoundaryVertex; 4] = array::from_fn(|index| BoundaryVertex { + uv: corner_uvs_clockwise[index], + position: subpatch.position_bezier_net.corners_clockwise()[index], + }); + + let corner_indices = |edge_index: usize| [edge_index, (edge_index + 1) % 4]; + let junction_vertex = |edge_index: usize| { + let [corner1_index, corner2_index] = corner_indices(edge_index); + let uv = (corner_uvs_clockwise[corner1_index] + corner_uvs_clockwise[corner2_index]) / 2.; + let position = evaluate_cubic_bezier_bernstein(&bezier_edge(&subpatch.position_bezier_net, edge_index), 0.5); + BoundaryVertex { uv, position } + }; + + // When the current subpatch has at least one junction, tessellate it by a triangle fan using the first junction as a pivot. + if let Some(pivot_edge_index) = subpatch.junctions.iter().enumerate().find(|(_, has_junction)| **has_junction).map(|(edge_index, _)| edge_index) { + let boundary_vertices = ALL_EDGES + .iter() + .flat_map(|edge| { + let edge_index = *edge as usize; + [Some(corner_vertices[edge_index]), subpatch.junctions[edge_index].then(|| junction_vertex(edge_index))] + }) + .flatten() + .collect::>(); + let vertex_count = boundary_vertices.len(); + + // Rotate to make the first vertex becomes the pivot + boundary_vertices.iter().cycle().skip(pivot_edge_index + 1).take(vertex_count).for_each(|vertex| { + vertices.push(MeshVertex { + patch_index, + uv: vertex.uv.as_vec2().to_array(), + position: self.mesh_to_texture.transform_point2(vertex.position).as_vec2().to_array(), + }); + }); + let local_vertex_indices = (1..vertex_count).collect::>(); + indices.extend( + local_vertex_indices + .windows(2) + .flat_map(|pair| [base_vertex_index, base_vertex_index + pair[0] as u32, base_vertex_index + pair[1] as u32]), + ); + } else { + vertices.extend(subpatch.position_bezier_net.corners_clockwise().iter().zip(corner_uvs_clockwise.iter()).map(|(pos, uv)| MeshVertex { + patch_index: subpatch.patch_index as u32, + uv: uv.as_vec2().to_array(), + position: self.mesh_to_texture.transform_point2(*pos).as_vec2().to_array(), + })); + indices.extend(RECT_TO_TRIANGLE_INDICES.iter().map(|&corner_index| corner_index + base_vertex_index)); + } + } + + Ok((vertices, indices)) + } + + fn subdivide_mesh_adaptive(&self) -> Result, MeshGradientTessellatorError> { + let mut state = self.initialize_adaptive_subdivision()?; + + while let Some(key) = state.next_refinement_key() { + self.refine_subpatch(&mut state, key); + } + + self.mark_t_junctions(&mut state); + + Ok(state.subpatches.into_values().filter(|subpatch| !subpatch.is_subdivided).collect()) + } + + fn initialize_adaptive_subdivision(&self) -> Result { + let mut state = AdaptiveSubdivisionState::default(); + + for patch in self.evaluator.patches() { + if self.should_cull(&patch.position_bezier_net()) { + continue; + }; + + let root = Subpatch { + patch_index: patch.index(), + position_bezier_net: patch.position_bezier_net(), + uv_bounds: [DVec2::new(0., 0.), DVec2::new(1., 1.)], + is_subdivided: false, + balance_queued: false, + junctions: [false; 4], + }; + let deviation = self + .subpatch_tessellation_error_bound_px(&root.position_bezier_net) + .map_err(|_| MeshGradientTessellatorError::Subpatches)?; + let patch_root_key = (root.patch_index, MortonCode::root()); + if deviation > MESH_POSITION_DEVIATION_TOLERANCE { + state.deviation_queue.push_back((deviation, patch_root_key)); + }; + state.subpatches.insert(patch_root_key, root); + } + + Ok(state) + } + + fn refine_subpatch(&self, state: &mut AdaptiveSubdivisionState, key: SubpatchKey) { + let morton_code = key.1; + if morton_code.depth() == MAX_SUBDIVISION_DEPTH { + return; + } + + let (patch_index, subdivided_uv_bounds, subdivided_nets) = { + let Some(target) = state.subpatches.get_mut(&key) else { return }; + + if target.is_subdivided { + return; + }; + target.is_subdivided = true; + let patch_index = key.0; + let subdivided_uv_bounds = subdivide_uv_bounds(target.uv_bounds); + let subdivided_nets = target.position_bezier_net.subdivide(); + (patch_index, subdivided_uv_bounds, subdivided_nets) + }; + + for quadrant in ALL_QUADRANTS { + let i = quadrant as usize; + if self.should_cull(&subdivided_nets[i]) { + continue; + }; + + let child_morton_code = morton_code.child(quadrant); + let Ok(child_deviation) = self.subpatch_tessellation_error_bound_px(&subdivided_nets[i]) else { + continue; + }; + let child_subpatch = Subpatch { + patch_index, + position_bezier_net: subdivided_nets[i], + uv_bounds: subdivided_uv_bounds[i], + is_subdivided: false, + balance_queued: false, + junctions: [false; 4], + }; + let child_key = (patch_index, child_morton_code); + state.subpatches.insert(child_key, child_subpatch); + + if child_deviation > MESH_POSITION_DEVIATION_TOLERANCE { + state.deviation_queue.push_back((child_deviation, child_key)); + } + } + + self.enqueue_balance_refinements(state, patch_index, morton_code); + } + + fn enqueue_balance_refinements(&self, state: &mut AdaptiveSubdivisionState, patch_index: usize, morton_code: MortonCode) { + // Target neighbors of the parent cell of the [NW, NE, SW, SE] quadrant. + const BALANCING_EDGES_BY_QUADRANT: [[Edge; 2]; 4] = [[Edge::Top, Edge::Left], [Edge::Top, Edge::Right], [Edge::Bottom, Edge::Left], [Edge::Bottom, Edge::Right]]; + + let quadrant = morton_code.quadrant(); + let Some(parent_morton_code) = morton_code.try_parent() else { return }; + + for edge in BALANCING_EDGES_BY_QUADRANT[quadrant as usize] { + let Some(parent_neighbor_key) = self.neighbor_key(patch_index, parent_morton_code, edge) else { + continue; + }; + let Some(parent_neighbor) = state.subpatches.get_mut(&parent_neighbor_key) else { continue }; + if parent_neighbor.is_subdivided || parent_neighbor.balance_queued { + continue; + }; + parent_neighbor.balance_queued = true; + state.balance_queue.push_back(parent_neighbor_key); + } + } + + fn mark_t_junctions(&self, state: &mut AdaptiveSubdivisionState) { + // Collect if a subpatch has neighboring subdivided subpatches that create T-junctions. + // These are necessary to be vertices after the tessellation process. + let junctions = state + .subpatches + .iter() + .filter(|(_, subpatch)| !subpatch.is_subdivided) + .map(|(key, _)| { + let (patch_index, morton_code) = *key; + let current_junctions = ALL_EDGES.map(|edge| { + let Some(neighbor_key) = self.neighbor_key(patch_index, morton_code, edge) else { return false }; + let neighbor = state.subpatches.get(&neighbor_key); + neighbor.is_some_and(|n| n.is_subdivided) + }); + + (*key, current_junctions) + }) + .collect::>(); + + junctions.into_iter().for_each(|(key, junctions)| { + let Some(subpatch) = state.subpatches.get_mut(&key) else { return }; + subpatch.junctions = junctions; + }); + } + + fn neighbor_key(&self, patch_index: usize, morton_code: MortonCode, edge: Edge) -> Option { + let (patch_rows, patch_columns) = self.evaluator.patch_dimension(); + let patch_col = patch_index as i64 % patch_columns; + let patch_row = patch_index as i64 / patch_columns; + + let [cell_x, cell_y] = morton_code.coordinates(); + let [dx, dy] = match edge { + Edge::Top => [0, -1], + Edge::Bottom => [0, 1], + Edge::Left => [-1, 0], + Edge::Right => [1, 0], + }; + let neighbor_cell_x = cell_x as i64 + dx; + let neighbor_cell_y = cell_y as i64 + dy; + + let depth = morton_code.depth(); + let cells_per_axis = 2_i64.pow(depth); + + // The parent's neighbor is possibly not within the same quadtree + let neighbor_patch_x = patch_col + neighbor_cell_x.div_euclid(cells_per_axis); + let neighbor_patch_y = patch_row + neighbor_cell_y.div_euclid(cells_per_axis); + if neighbor_patch_x < 0 || neighbor_patch_x >= patch_columns || neighbor_patch_y < 0 || neighbor_patch_y >= patch_rows { + return None; + } + let neighbor_patch_index = neighbor_patch_x + neighbor_patch_y * patch_columns; + let neighbor_subpatch_x = neighbor_cell_x.rem_euclid(cells_per_axis) as u64; + let neighbor_subpatch_y = neighbor_cell_y.rem_euclid(cells_per_axis) as u64; + let neighbor_morton_code = MortonCode::from_coordinates(depth, neighbor_subpatch_x, neighbor_subpatch_y); + + Some((neighbor_patch_index as usize, neighbor_morton_code)) + } + + /// Measures how far the rendered approximation of one subpatch goes from the target. + fn subpatch_tessellation_error_bound_px(&self, subpatch_net: &BicubicBezierNet) -> Result { + // Compute an upper bound on the shape error between the target subpatch and the bilinear patch defined by its four corners, using the convex hull property of Bézier surfaces. + let approximated = BicubicBezierNet::from_quadrilateral(&subpatch_net.corners()); + let differences = *subpatch_net - approximated; + let shape_error_px = differences + .iter() + .flatten() + .map(|diff| (self.mesh_to_output.matrix2 * diff).length()) + .max_by(|a, b| a.total_cmp(b)) + .expect("DIfferences length must be greater than 0"); + + let [top_left, top_right, bottom_left, bottom_right] = subpatch_net.corners(); + // Calculate the maximum error between the bilinear quadrilateral mapping and the piecewise-linear mapping produced by barycentric interpolation over two triangles. + // Their difference is `u * v * D` in one triangle and `(1 - u) * (1 - v) * D` in the other, where `D` is the bilinear mixed difference. + // Both scalar factors have a maximum of 1/4 at the midpoint of the shared diagonal. + // https://gpuopen.com/learn/bilinear-interpolation-quadrilateral-barycentric-coordinates/ + // TODO: possibly better to do the reverse calculation in the fragment shader + let bilerp_error_px = (self.mesh_to_output.matrix2 * (top_left - top_right - bottom_left + bottom_right)).length() / 4.; + + PositionDeviationBound::new(shape_error_px + bilerp_error_px) + } +} + +#[repr(u8)] +#[derive(Copy, Clone)] +enum Quadrant { + NW, + NE, + SW, + SE, +} + +const ALL_QUADRANTS: [Quadrant; 4] = [Quadrant::NW, Quadrant::NE, Quadrant::SW, Quadrant::SE]; + +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)] +struct MortonCode(u64); + +impl MortonCode { + fn root() -> Self { + // Use a leading 1 bit as a sentinel to preserve depth information in the Morton code. + const MORTON_SENTINEL: u64 = 1; + Self(MORTON_SENTINEL) + } + + fn from_coordinates(depth: u32, x: u64, y: u64) -> Self { + Self((1 << (depth * 2)) | expand_morton_bits(x) | expand_morton_bits(y) << 1) + } + + fn depth(&self) -> u32 { + self.0.ilog2() / 2 + } + + fn quadrant(&self) -> Quadrant { + let index = self.0 & 0b11; + match index { + 0 => Quadrant::NW, + 1 => Quadrant::NE, + 2 => Quadrant::SW, + 3 => Quadrant::SE, + _ => unreachable!("Morton code cannot point outside of the quadrant"), + } + } + + fn child(&self, quadrant: Quadrant) -> Self { + Self(self.0 << 2 | quadrant as u64) + } + + fn try_parent(&self) -> Option { + let parent_code = self.0 >> 2; + if parent_code == 0 { + return None; + } + Some(Self(parent_code)) + } + + fn coordinates(&self) -> [u64; 2] { + let code = self.0; + let without_sentinel = (1 << code.ilog2()) ^ code; + let x = compact_morton_bits(without_sentinel & MORTON_COORDINATE_MASK); + let y = compact_morton_bits((without_sentinel >> 1) & MORTON_COORDINATE_MASK); + [x, y] + } +} + +const MORTON_COORDINATE_MASK: u64 = 0x5555_5555_5555_5555; + +// Index subpatches by the pair of patch index and Morton code for expected O(1) lookup of given ancestors or neighbors, without storing parent pointers. +type SubpatchKey = (usize, MortonCode); + +#[derive(Clone)] +struct Subpatch { + patch_index: usize, + position_bezier_net: BicubicBezierNet, + uv_bounds: [DVec2; 2], + is_subdivided: bool, + balance_queued: bool, + /// Stores if edges have T-junction. Use `Edge` to access. + junctions: [bool; 4], +} + +#[derive(Default)] +struct AdaptiveSubdivisionState { + subpatches: HashMap, + /// Queue for deviation-based adaptive subdivision. + deviation_queue: VecDeque<(PositionDeviationBound, SubpatchKey)>, + /// Queue for balancing the quadtree. + balance_queue: VecDeque, +} + +impl AdaptiveSubdivisionState { + fn next_refinement_key(&mut self) -> Option { + self.balance_queue.pop_front().or_else(|| self.deviation_queue.pop_front().map(|(_, key)| key)) + } +} + +#[repr(u8)] +#[derive(Copy, Clone, PartialEq)] +enum Edge { + Top, + Right, + Bottom, + Left, +} + +const ALL_EDGES: [Edge; 4] = [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left]; + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct PositionDeviationBound(f64); + +impl PositionDeviationBound { + pub fn new(value: f64) -> Result { + if value.is_finite() { Ok(Self(value)) } else { Err(NotFiniteError(value)) } + } + + pub const fn get(self) -> f64 { + self.0 + } +} + +impl Eq for PositionDeviationBound {} + +impl PartialOrd for PositionDeviationBound { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PositionDeviationBound { + fn cmp(&self, other: &Self) -> Ordering { + self.0.partial_cmp(&other.0).expect("Finite does not have NaN") + } +} + +impl fmt::Display for PositionDeviationBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for PositionDeviationBound { + type Error = NotFiniteError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + +impl From for f64 { + fn from(value: PositionDeviationBound) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NotFiniteError(pub f64); + +impl fmt::Display for NotFiniteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Not finite value: {}", self.0) + } +} + +impl std::error::Error for NotFiniteError {} + +fn compact_morton_bits(n: u64) -> u64 { + let n1 = (n >> 1 | n) & 0x3333_3333_3333_3333; + let n2 = (n1 >> 2 | n1) & 0x0f0f_0f0f_0f0f_0f0f; + let n3 = (n2 >> 4 | n2) & 0x00ff_00ff_00ff_00ff; + let n4 = (n3 >> 8 | n3) & 0x0000_ffff_0000_ffff; + (n4 >> 16 | n4) & 0x0000_0000_ffff_ffff +} + +fn expand_morton_bits(n: u64) -> u64 { + let n1 = (n << 16 | n) & 0x0000_ffff_0000_ffff; + let n2 = (n1 << 8 | n1) & 0x00ff_00ff_00ff_00ff; + let n3 = (n2 << 4 | n2) & 0x0f0f_0f0f_0f0f_0f0f; + let n4 = (n3 << 2 | n3) & 0x3333_3333_3333_3333; + (n4 << 1 | n4) & 0x5555_5555_5555_5555 +} + +fn subdivide_uv_bounds(uv_bounds: [DVec2; 2]) -> [[DVec2; 2]; 4] { + let [uv_min, uv_max] = uv_bounds; + let uv_mid = (uv_min + uv_max) / 2.; + [ + [uv_min, uv_mid], + [DVec2::new(uv_mid.x, uv_min.y), DVec2::new(uv_max.x, uv_mid.y)], + [DVec2::new(uv_min.x, uv_mid.y), DVec2::new(uv_mid.x, uv_max.y)], + [uv_mid, uv_max], + ] +} + +fn bezier_edge(net: &BicubicBezierNet, edge_index: usize) -> [DVec2; 4] { + match edge_index { + 0 => [net[0][0], net[0][1], net[0][2], net[0][3]], + 1 => [net[0][3], net[1][3], net[2][3], net[3][3]], + 2 => [net[3][3], net[3][2], net[3][1], net[3][0]], + 3 => [net[3][0], net[2][0], net[1][0], net[0][0]], + _ => unreachable!(), + } +} diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index a4d6de3db27..dd62eb0c6a1 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -11,7 +11,7 @@ use rand::seq::SliceRandom; use raster_types::{CPU, GPU, Raster}; use std::cmp::Ordering; use vector_types::gradient::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread}; -use vector_types::{Gradient, ReferencePoint}; +use vector_types::{Gradient, MeshGradient, ReferencePoint}; /// Returns the list with the item at the specified index removed. /// If no value exists at that index, the list is returned unchanged. @@ -942,6 +942,7 @@ pub async fn into_group + 'n>( List>, List, List, + List, List, List, Item, // TODO: Remove this diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 2385c9a9b68..8524b483701 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -9,7 +9,12 @@ license = "MIT OR Apache-2.0" [features] default = ["wgpu"] gpu = [] -wgpu = ["gpu", "graph-craft/wgpu", "graphene-application-io/wgpu", "graphene-canvas-utils?/wgpu"] +wgpu = [ + "gpu", + "graph-craft/wgpu", + "graphene-application-io/wgpu", + "graphene-canvas-utils?/wgpu", +] wasm = [ "wasm-bindgen", "wasm-bindgen-futures", @@ -24,7 +29,7 @@ wasm = [ "vector-nodes/wasm", "graphene-core/wasm", "graph-craft/wasm", - "dep:graphene-canvas-utils" + "dep:graphene-canvas-utils", ] image-compare = [] vello = ["gpu"] @@ -49,6 +54,7 @@ rendering = { workspace = true } graphene-application-io = { workspace = true } raster-nodes = { workspace = true } brush-nodes = { workspace = true } +gradient-nodes = { workspace = true } graphene-core = { workspace = true } graphic-nodes = { workspace = true } repeat-nodes = { workspace = true } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 4090049bd11..10dbc71460a 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -8,6 +8,7 @@ pub mod text; pub use blending_nodes; pub use brush_nodes as brush; pub use core_types::*; +pub use gradient_nodes; pub use graphene_application_io as application_io; pub use graphene_core; pub use graphene_core::debug; @@ -54,7 +55,7 @@ pub mod artboard { } 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 c793a8f9612..10ac91ebcd4 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -13,7 +13,7 @@ use math_parser::context::{EvalContext, NothingMap, ValueProvider}; use math_parser::value::{Number, Value}; use rand::{Rng, SeedableRng}; use std::ops::{Add, 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. diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index fdef8370448..52f0f6f404b 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -265,9 +265,9 @@ fn flatten_vector(graphic_list: &List) -> List { boolean_operation_on_vector_list(&flattened, BooleanOperation::Union).into_iter().collect::>() } } - // Rasters, colors, and gradients bound no region, so they contribute no operand - Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) => Vec::new(), - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) => Vec::new(), + // Rasters, colors, and gradients (mesh gradients included) bound no region, so they contribute no operand + Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::MeshGradient(_) => Vec::new(), + Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) | Graphic::MeshGradientList(_) => Vec::new(), // Strokes have no vector outline representation; a brush node renders them to rasters Graphic::StrokeList(_) => Vec::new(), // Normalized to GraphicList above diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 912ba87b254..6c242804c33 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -4,12 +4,13 @@ use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{ATTR_APPEARANCE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; +use core_types::paint::PaintRenderParams; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; -use core_types::transform::{Footprint, Transform}; +use core_types::transform::{ApplyTransform, Footprint, Transform}; use core_types::uuid::NodeId; use core_types::{ ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_FORM, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CloneVarArgs, Color, Context, Ctx, - ExtractAll, OwnedContextImpl, + ExtractAll, InjectPaintRenderParams, OwnedContextImpl, }; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Vector; @@ -21,8 +22,7 @@ use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArcle use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use vector_types::GradientForm; -use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; +use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box, initial_mesh_gradient_transform_for_bounding_box}; use vector_types::vector::algorithms::bezpath_algorithms::{ self, TValue, bezpath_area_centroid_and_area, bezpath_length_centroid_and_length, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath, }; @@ -35,6 +35,7 @@ use vector_types::vector::misc::{ }; use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{PointDomain, PointId, SegmentDomain, SegmentId, VectorExt}; +use vector_types::{GradientForm, 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`. @@ -85,6 +86,28 @@ impl VectorListIterMut for List { } } +/// The bounding box a paint falls back to when it carries no explicit placement of its own. +fn paint_target_bounds(content: &mut impl VectorItemMut) -> [DVec2; 2] { + 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 [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); + if max.x - min.x < 1e-10 { + max.x = min.x + 1.; + } + if max.y - min.y < 1e-10 { + max.y = min.y + 1.; + } + [min, max] +} + /// Element-level analog of [`VectorListIterMut`] for the element-wise fill and stroke nodes, operating on a /// single `Item` or `Item`. trait VectorItemMut { @@ -304,29 +327,43 @@ where /// Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. #[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("fill_properties"))] async fn fill( - _: impl Ctx, + ctx: impl Ctx + ExtractAll + CloneVarArgs + InjectPaintRenderParams, /// The content with vector paths to apply the fill style to. #[implementations(Vector, Graphic)] content: Item, - #[default(Color::BLACK)] paint: Item, + // FIXME: Discuss preparing mesh-gradient textures before render_intermediate instead of at Fill inputs. Tagged values on Fill cannot trigger GPU pipeline. + #[implementations(Context -> List)] paint: impl Node<'n, Context<'static>, Output = List>, _backup_color: Item, #[default(Color::BLACK, Color::WHITE)] _backup_gradient: Item, _gradient_form: Item, _has_transform: Item, _transform: Item, + _backup_mesh_gradient: Item, + _has_mesh_transform: Item, + _mesh_transform: Item, ) -> Item where Item: VectorItemMut + 'n + Send, { let _gradient_form = _gradient_form.into_element(); let (_has_transform, _transform) = (_has_transform.into_element(), *_transform.element()); + let (_has_mesh_transform, _mesh_transform) = (_has_mesh_transform.into_element(), *_mesh_transform.element()); let mut content = content; + // FIXME: find the way to avoid evaluating transform both from fill and gpu node + let fallback_paint_to_target = (!_has_mesh_transform).then(|| initial_mesh_gradient_transform_for_bounding_box(paint_target_bounds(&mut content))); // The paint is the element alone: keeping the wire envelope's attributes would nest the paint as a group, changing how it renders - let mut paint = paint.into_element(); + let item_transform = content.attribute_cloned_or_default::(ATTR_TRANSFORM); + let mut paint_ctx = OwnedContextImpl::from(ctx.clone()).with_paint_render_params(PaintRenderParams { fallback_paint_to_target }); + if let Some(mut footprint) = ctx.try_footprint().copied() { + footprint.apply_transform(&item_transform); + paint_ctx.set_footprint(footprint); + }; + let paint_list: List = paint.eval(paint_ctx.into_context()).await; + let mut paint = paint_list.into_iter().next().map(|item| item.into_element()).unwrap_or_default(); - // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire - let (needs_form, needs_transform) = match &paint { + // Stamp the styling inputs onto any gradient or mesh-gradient paint missing them, whether the paint arrived as a picker value or a wire + let (needs_form, needs_gradient_transform) = match &paint { Graphic::Gradient(item) => (item.attribute::(ATTR_GRADIENT_FORM).is_none(), item.attribute::(ATTR_TRANSFORM).is_none()), Graphic::GradientList(list) => ( list.iter_attribute_values::(ATTR_GRADIENT_FORM).is_none(), @@ -335,39 +372,27 @@ where _ => (false, false), }; - let stamped_transform = needs_transform.then(|| { + let needs_mesh_transform = match &paint { + Graphic::MeshGradient(item) => item.attribute::(ATTR_TRANSFORM).is_none(), + Graphic::MeshGradientList(list) => list.iter_attribute_values::(ATTR_TRANSFORM).is_none(), + _ => false, + }; + + let stamped_gradient_transform = needs_gradient_transform.then(|| { // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) if _has_transform { return _transform; } - - 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]); - 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]) + initial_gradient_transform_for_bounding_box(paint_target_bounds(&mut content)) }); + let stamped_mesh_transform = needs_mesh_transform.then(|| fallback_paint_to_target.unwrap_or(_mesh_transform)); match &mut paint { Graphic::Gradient(item) => { if needs_form { item.set_attribute(ATTR_GRADIENT_FORM, _gradient_form); } - if let Some(transform) = stamped_transform { + if let Some(transform) = stamped_gradient_transform { item.set_attribute(ATTR_TRANSFORM, transform); } } @@ -377,7 +402,19 @@ where *value = _gradient_form; } } - if let Some(transform) = stamped_transform { + if let Some(transform) = stamped_gradient_transform { + for value in list.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } + } + Graphic::MeshGradient(item) => { + if let Some(transform) = stamped_mesh_transform { + item.set_attribute(ATTR_TRANSFORM, transform); + } + } + Graphic::MeshGradientList(list) => { + if let Some(transform) = stamped_mesh_transform { for value in list.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { *value = transform; }