diff --git a/Cargo.lock b/Cargo.lock index bafa4765fc..7e8186c67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4876,6 +4876,7 @@ dependencies = [ "graphene-hash", "graphene-resource", "graphic-types", + "image", "kurbo", "log", "num-traits", diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..4b882d7a91 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 73053ed3e7..c3852d7e59 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 @@ -26,7 +26,7 @@ use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, 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}; @@ -208,6 +208,7 @@ fn generate_layout(introspected_data: &Arc>, List, List, + List, List, List, List, @@ -263,6 +264,7 @@ fn generate_layout(introspected_data: &Arc>, Item, Item, + Item, Item, Item, Item, @@ -596,6 +598,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(), @@ -604,6 +607,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(), } } @@ -620,6 +624,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), @@ -628,6 +633,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), } } @@ -807,6 +813,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)] + } +} + impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number (f64)" @@ -1233,6 +1266,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 ae83a96c0f..d05991fee2 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 @@ -10,6 +10,7 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; 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, PointId, VectorModificationType}; @@ -32,6 +33,10 @@ pub enum GraphOperationMessage { gradient_settings: GradientSettings, transform: DAffine2, }, + FillMeshGradientSet { + layer: LayerNodeIdentifier, + mesh_gradient: MeshGradientSurface, + }, BlendingFillSet { layer: LayerNodeIdentifier, fill: f64, @@ -77,6 +82,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 0aa20e1d4e..c41f976b7d 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 @@ -55,6 +55,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); @@ -110,6 +115,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 79c80f5c09..a2577b2dc6 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; @@ -18,6 +18,7 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; 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, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -587,6 +588,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::math_nodes::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 9b0809a5ab..d30afb6fa0 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -18,6 +18,7 @@ use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, No use graph_craft::{Type, concrete}; use graphene_std::animation::RealTimeMode; use graphene_std::brush::brush_stroke::BrushTrace; +use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ @@ -33,8 +34,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}; @@ -641,7 +642,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()); @@ -660,7 +674,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.]); @@ -669,22 +688,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(), ]); @@ -694,14 +719,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()]); @@ -709,28 +730,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(), ]); @@ -2378,6 +2391,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 { @@ -2411,6 +2432,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, } @@ -2429,6 +2453,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)) @@ -2448,7 +2473,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), @@ -2458,9 +2483,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 { @@ -2486,13 +2515,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| { @@ -2534,21 +2564,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)]; @@ -2565,19 +2597,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 17ea05b22a..1c09727084 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -67,7 +67,7 @@ pub fn selected_segments_for_layer(vector: &Vector, state: &SelectedLayerState) selected_segments } -fn overlay_bezier_handles(bezier: Bezier, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { +pub fn overlay_bezier_handles(bezier: Bezier, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { let bezier = bezier.apply_transformation(|point| transform.transform_point2(point)); let not_under_anchor = |position: DVec2, anchor: DVec2| position.distance_squared(anchor) >= HIDE_HANDLE_DISTANCE * HIDE_HANDLE_DISTANCE; 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 0a7cc9deb9..0e764e0482 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 d93ab6e615..2cb9d6808c 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1820,6 +1820,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 3b99ad1afb..1ffb7576ad 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 e66ef4f783..d796bab955 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -8,12 +8,15 @@ 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::subpath::Subpath; 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; @@ -477,6 +480,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 { @@ -497,6 +520,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::math_nodes::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::math_nodes::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 02f28e0191..331fa191eb 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 c3cc9d8d84..269f4dde7c 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 }), @@ -374,6 +375,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 0000000000..2493d08088 --- /dev/null +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -0,0 +1,1143 @@ +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::subpath::{BezierHandles, pathseg_points}; +use graphene_std::vector::algorithms::util::pathseg_tangent; +use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; +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, bezier, _, _) in geometry.segment_bezier_iter() { + overlay_bezier_handles(bezier, 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, bezier, _, _) in gradient.geometry().segment_bezier_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)); + } + }; + + match bezier.handles { + BezierHandles::Linear => {} + BezierHandles::Quadratic { handle } => { + consider_handle(HandleId::primary(segment_id), handle, bezier.start, Some(bezier.end)); + } + BezierHandles::Cubic { handle_start, handle_end } => { + consider_handle(HandleId::primary(segment_id), handle_start, bezier.start, None); + consider_handle(HandleId::end(segment_id), handle_end, bezier.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); + + if let Some(gradient) = constrained_gradient { + 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) { + 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) { + 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 6d29ad81a9..305dc5a69d 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 0e9adbe7a3..7057b09315 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 6d384a7daa..067d0addf1 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -407,22 +407,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 2d526123ba..5f5923b85b 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), /// Stored compactly as a `Vec`, materializes as the single-value `Item` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code #[serde(alias = "BrushStrokeTable")] @@ -139,6 +141,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::BrushStrokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS @@ -202,6 +205,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::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -265,6 +269,7 @@ macro_rules! tagged_value { Self::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::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -294,6 +299,7 @@ macro_rules! tagged_value { Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), Self::GradientRamp(_) => item!(Gradient), + Self::MeshGradient(_) => item!(MeshGradient), Self::BrushStrokes(_) => item!(BrushTrace), // ======================= // AUTO-GENERATED VARIANTS @@ -333,6 +339,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::BrushStrokes(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(downcast::>(input).unwrap().into_element().0.iter_element_values().cloned().collect())), // ======================= @@ -367,6 +375,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::BrushStrokes(input.downcast_ref::>().unwrap().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().element().0.iter_element_values().cloned().collect())), // ======================= @@ -396,6 +406,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::BrushStrokes(Vec::new())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time @@ -450,6 +461,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::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 85970e91b0..db58d1b779 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(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)] + vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index da018f3ab5..fc724c01cf 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::brush_stroke::BrushTrace; use graphene_std::extract_xy::XY; -use graphene_std::gradient::Gradient; +use graphene_std::gradient::{Gradient, MeshGradient}; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] use graphene_std::platform_application_io::canvas_utils::CanvasHandle; @@ -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/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 34103385fc..f2dea92cde 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -12,7 +12,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)] @@ -25,6 +25,7 @@ pub enum Graphic { RasterGPU(Item>), Color(Item), Gradient(Item), + MeshGradient(Box>), Text(Item), NoneList(List), GraphicList(List), @@ -33,6 +34,7 @@ pub enum Graphic { RasterGPUList(List>), ColorList(List), GradientList(List), + MeshGradientList(List), TextList(List), } @@ -140,6 +142,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) + } +} + // String impl From for Graphic { fn from(text: String) -> Self { @@ -303,12 +322,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), // A color has no spatial extent, so there is no placement for a transform to move Graphic::None(_) | Graphic::NoneList(_) | Graphic::Color(_) | Graphic::ColorList(_) => {} @@ -431,6 +452,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 { List::new_from_element(Graphic::TextList(self)) @@ -533,6 +560,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), @@ -541,6 +569,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), } } @@ -594,6 +623,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, } } @@ -620,6 +651,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), @@ -640,11 +673,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(), @@ -794,6 +828,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), @@ -801,6 +836,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), } } @@ -814,6 +850,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), @@ -821,6 +858,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), } } @@ -836,13 +874,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(), } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 13facc359c..9d3f393423 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -31,6 +31,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 0bea6f6cda..ac2fa3bbd8 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -290,11 +290,13 @@ impl RenderExt for List { | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Text(_)) + | Some(Graphic::MeshGradient(_)) | Some(Graphic::VectorList(_)) | Some(Graphic::RasterCPUList(_)) | Some(Graphic::RasterGPUList(_)) | Some(Graphic::GraphicList(_)) - | Some(Graphic::TextList(_)) => { + | Some(Graphic::TextList(_)) + | Some(Graphic::MeshGradientList(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; @@ -315,7 +317,7 @@ impl RenderExt for List { } /// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID. -/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet. +/// Currently, this function is only used for clipping-based filling and stroking and mesh gradient, not considering tiling yet. fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { let min = bounds.transform_point2(DVec2::ZERO); let max = bounds.transform_point2(DVec2::ONE); @@ -324,14 +326,16 @@ fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, 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; fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); // Unwrap the inner def element write!(svg_defs, "{}", content.svg_defs).unwrap(); - let pattern_transform = 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 407e7f21d5..0af7951ba8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,11 +1,13 @@ +mod mesh_gradient; + use crate::render_ext::{PaintTarget, RenderExt}; +use crate::renderer::mesh_gradient::{ + MESH_COLOR_ERROR_TOLERANCE, MESH_POSITION_ERROR_TOLERANCE, SvgMeshPatchRenderer, render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, +}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; -use core_types::CacheHash; use core_types::blending::BlendMode; -use core_types::bounds::BoundingBox; -use core_types::bounds::RenderBoundingBox; -use core_types::color::Color; -use core_types::color::SRGBA8; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::color::{Color, SRGBA8}; use core_types::consts::DEFAULT_FONT_SIZE; use core_types::list::ATTR_APPEARANCE; use core_types::list::{Item, List, NodeIdPath}; @@ -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; @@ -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. @@ -1000,6 +1004,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), @@ -1007,6 +1012,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), } } @@ -1027,6 +1033,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, 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), @@ -1034,6 +1041,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), } } @@ -1069,6 +1077,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); @@ -1102,6 +1111,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); @@ -1121,6 +1138,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), @@ -1128,6 +1146,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), } } @@ -1141,6 +1160,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), @@ -1148,6 +1168,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), } } @@ -1161,6 +1182,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), @@ -1168,6 +1190,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), } } @@ -1208,6 +1231,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); @@ -1232,7 +1256,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); } @@ -1799,7 +1823,9 @@ fn render_vector_item_to_vello( | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) - | Graphic::TextList(_) => { + | Graphic::TextList(_) + | Graphic::MeshGradient(_) + | Graphic::MeshGradientList(_) => { 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); scene.pop_layer(); @@ -1899,7 +1925,9 @@ fn render_vector_item_to_vello( | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) - | Graphic::TextList(_) => { + | Graphic::TextList(_) + | Graphic::MeshGradient(_) + | Graphic::MeshGradientList(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); @@ -2603,6 +2631,20 @@ fn gradient_control_outline(gradient_form: GradientForm) -> Subpath ClickTarget { + let subpaths = mesh + .patches() + .flatten() + .map(|patch| { + let [top, bottom, left, right] = patch.edges; + Subpath::from_beziers(&[top, right, bottom.reverse(), left.reverse()], true) + }) + .collect::>(); + + ClickTarget::new_with_compound_path(subpaths, 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 @@ -2875,6 +2917,219 @@ 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, 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) 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, render_params: &RenderParams) { + use vello::peniko; + let Some(mesh_gradient) = item.element() else { return }; + + if let RenderMode::Outline = render_params.render_mode { + return; + } + + let infinite_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); + let mesh_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let has_transparency = mesh_gradient.corners().any(|corner| !corner.color.is_opaque()); + let blend_mode_attr: 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 space: GradientSpace = item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); + let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); + let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { return }; + let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, MESH_POSITION_ERROR_TOLERANCE, MESH_COLOR_ERROR_TOLERANCE) else { + return; + }; + + // Vello approximates each Coons patch in two stages: + // + // 1. Adaptively subdivide its geometry into sufficiently accurate parallelograms. + // 2. Paint each subpatch from two adaptively sampled horizontal edge gradients blended by an adaptively sampled vertical mask. + // + // The subpatch is inflated to hide rasterization seams, then the completed color is clipped once so + // overlapping paint does not receive edge coverage independently. + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let mut item_layer = false; + if opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &infinite_rect); + item_layer = true; + } + + // Clip all inflated subpatches to the original mesh boundary. + let mesh_boundary = mesh_gradient.boundary_path(); + scene.push_layer( + peniko::Fill::NonZero, + peniko::Mix::Normal, + 1., + kurbo::Affine::new((parent_transform * mesh_transform).to_cols_array()), + &mesh_boundary, + ); + + for patch_subpatches in subpatches.chunk_by(|a, b| a.patch_index == b.patch_index) { + let Some(patch_evaluator) = evaluator.patch_evaluator(patch_subpatches[0].patch_index) else { + continue; + }; + + for subpatch in patch_subpatches { + render_vello_subpatch_color(scene, patch_evaluator, subpatch, parent_transform); + } + } + + if has_transparency { + // Render alpha as an inflated opaque grayscale field, then use its luminance to mask the completed RGB mesh once. + // Opaque overlap avoids both transparent accumulation and anti-aliasing gaps between subpatches. + scene.push_luminance_mask_layer(peniko::Fill::NonZero, 1., kurbo::Affine::scale(f64::INFINITY), &infinite_rect); + for patch_subpatches in subpatches.chunk_by(|a, b| a.patch_index == b.patch_index) { + let Some(patch_evaluator) = evaluator.patch_evaluator(patch_subpatches[0].patch_index) else { + continue; + }; + + for subpatch in patch_subpatches { + render_vello_subpatch_alpha(scene, patch_evaluator, subpatch, parent_transform); + } + } + scene.pop_layer(); + } + scene.pop_layer(); + + if item_layer { + scene.pop_layer(); + } +} + +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 0000000000..a928b411b8 --- /dev/null +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -0,0 +1,1284 @@ +use std::collections::VecDeque; +use std::fmt::Write; +use std::ops::{Add, Mul, Sub}; + +use crate::renderer::{gradient_placement, singular_values, transform_is_invertible}; +use crate::to_peniko::ToPenikoColor; +use crate::{SvgRender, format_transform_matrix}; +use base64::Engine; +use core_types::uuid::generate_uuid; +use core_types::{Color, color::SRGBA8}; +use glam::{DAffine2, DMat2, DVec2, Vec2, 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}, +}; +use vello::{Scene, peniko}; + +/// Maximum allowed geometry approximation error in viewport pixels. +pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; +/// Maximum allowed color approximation error per channel. +pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; +/// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. +/// A mesh with more patches than this still emits one subpatch each, since a patch cannot render without its own region. +pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; +/// Smallest uv stride a region may refine to. +const MINIMUM_SUBPATCH_STRIDE: f64 = 1. / 4096.; +/// Patch padding size for hiding anti-aliasing gaps. +pub(super) const PATCH_INFLATION_SIZE: f64 = 1.; + +/// 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.02; +/// Exterior texels evaluated around the patch to cover displacement-map filtering. +const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 2; +/// Maximum local inflation applied to a subpatch clip. +const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; + +// =================== +// 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, index: usize, u: f32) -> Vec4 { + match self { + Self::Stepped => Vec4::from_array(patch_evaluator.evaluate_color(0., 0.)), + Self::BicubicBernstein => patch_evaluator.evaluate_bicubic_bezier_row(index, u).expect("Bicubic Bernstein layers should have the control points"), + Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[index])), + } + } +} + +/// 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.patch_evaluators() { + 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 displacement maps +// ===================== + +pub(super) struct DisplacementMapSamples { + /// Displacement-map region in normalized bounding-box 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 target-to-source displacement samples mapping normalized patch-bounding-box positions to source UVs. +pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEvaluator, unit_to_patch_bbox: &DAffine2, boundary: &BezPath) -> DisplacementMapSamples { + let size = DISPLACEMENT_MAP_SIZE; + let margin = DISPLACEMENT_MAP_MARGIN_PERCENTAGE / (1. - 2. * DISPLACEMENT_MAP_MARGIN_PERCENTAGE); + let map_min = DVec2::splat(-margin); + let map_size = DVec2::splat(1. + 2. * margin); + 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 normalized_bbox = map_min + image_uv * map_size; + (normalized_bbox, unit_to_patch_bbox.transform_point2(normalized_bbox)) + }; + + // 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)).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::() +} + +// ============================== +// Vello subdivision and geometry +// ============================== + +pub(super) struct MeshSubpatch { + corner_positions: [DVec2; 4], + pub(super) patch_index: usize, + uv_bounds: [DVec2; 2], +} + +/// One region of a patch's uv square, kept alongside the error of approximating it with a single parallelogram. +struct PendingRegion { + patch_index: usize, + uv_start: DVec2, + stride: f64, + corner_positions: [DVec2; 4], + /// Error as a multiple of the tolerances, so position and color rank on one scale. At most 1 is within tolerance. + error: f64, +} + +/// How far an error overruns its tolerance. A zero tolerance admits only a zero error. +fn tolerance_overrun(error: f64, tolerance: f64) -> f64 { + if tolerance > 0. { + error / tolerance + } else if error > 0. { + f64::INFINITY + } else { + 0. + } +} + +/// Measures how far the rendered approximation of one region goes from the patch it covers. +/// `None` when the patch evaluates to a non-finite value there, which no amount of subdivision repairs. +fn measure_region( + patch: &MeshPatchEvaluator, + patch_index: usize, + uv_start: DVec2, + stride: f64, + mesh_transform: DAffine2, + parent_transform: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, +) -> Option { + const SAMPLES: [f64; 5] = [0., 0.25, 0.5, 0.75, 1.]; + + let corner_positions = [DVec2::ZERO, DVec2::new(stride, 0.), DVec2::new(0., stride), DVec2::splat(stride)] + .map(|offset| uv_start + offset) + .map(|uv| mesh_transform.transform_point2(patch.evaluate_position(uv.x, uv.y))); + let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; + + let color_weight_func = subpatch_color_weight(patch, uv_start.as_vec2(), (uv_start + DVec2::splat(stride)).as_vec2()); + + let mut error = 0_f64; + for &local_v in &SAMPLES { + for &local_u in &SAMPLES { + let u = uv_start.x + local_u * stride; + let v = uv_start.y + local_v * stride; + let expected_pos = mesh_transform.transform_point2(patch.evaluate_position(u, v)); + let expected_color = Vec4::from_array(patch.evaluate_color(u as f32, v as f32)); + // Approximate the position with the rendered parallelogram, then the color and alpha with the two + // passes that actually paint them: the color pass blends the edge rows by the projected weight, + // while the alpha pass ramps between them linearly. + let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; + let top_color = Vec4::from_array(patch.evaluate_color(u as f32, uv_start.y as f32)); + let bottom_color = Vec4::from_array(patch.evaluate_color(u as f32, (uv_start.y + stride) as f32)); + let approximated_color = bottom_color.lerp(top_color, color_weight_func(v as f32)); + let approximated_alpha = top_color.w + (bottom_color.w - top_color.w) * local_v as f32; + + let position_error = parent_transform.transform_vector2(expected_pos - approximated_pos).length(); + let color_error = (expected_color.truncate() - approximated_color.truncate()) + .abs() + .max_element() + .max((expected_color.w - approximated_alpha).abs()); + if !position_error.is_finite() || !color_error.is_finite() { + return None; + } + + error = error + .max(tolerance_overrun(position_error, position_error_tolerance)) + .max(tolerance_overrun(color_error as f64, color_error_tolerance as f64)); + } + } + + Some(PendingRegion { + patch_index, + uv_start, + stride, + corner_positions, + error, + }) +} + +/// Subdivides the patches until every region's parallelogram approximation is within the position and color tolerances, or the subpatch budget runs out. +pub(super) fn subdivide_patches_adaptive( + evaluator: &MeshGradientEvaluator, + mesh_transform: DAffine2, + parent_transform: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, +) -> Option> { + if !position_error_tolerance.is_finite() || position_error_tolerance < 0. || !color_error_tolerance.is_finite() || color_error_tolerance < 0. { + return None; + } + + let patches = evaluator.patch_evaluators().collect::>(); + let measure = |patch_index: usize, uv_start, stride| { + measure_region( + patches[patch_index], + patch_index, + uv_start, + stride, + mesh_transform, + parent_transform, + position_error_tolerance, + color_error_tolerance, + ) + }; + + let mut regions = (0..patches.len()).map(|patch_index| measure(patch_index, DVec2::ZERO, 1.)).collect::>>()?; + + // Every patch owes at least its own root region, so the cap bounds the refinement on top of that rather than the total + let budget = MESH_MAXIMUM_SUBPATCHES.max(regions.len()); + while regions.len() + 3 <= budget { + let worst = regions + .iter() + .enumerate() + .filter(|(_, region)| region.error > 1. && region.stride > MINIMUM_SUBPATCH_STRIDE) + .max_by(|(_, first), (_, second)| first.error.total_cmp(&second.error)) + .map(|(index, _)| index); + let Some(worst) = worst else { break }; + + let region = regions.swap_remove(worst); + let half_stride = region.stride / 2.; + for offset in [DVec2::ZERO, DVec2::new(half_stride, 0.), DVec2::new(0., half_stride), DVec2::splat(half_stride)] { + regions.push(measure(region.patch_index, region.uv_start + offset, half_stride)?); + } + } + + Some( + regions + .into_iter() + .map(|region| MeshSubpatch { + corner_positions: region.corner_positions, + patch_index: region.patch_index, + uv_bounds: [region.uv_start, region.uv_start + DVec2::splat(region.stride)], + }) + .collect(), + ) +} + +/// Returns the affine approximation of a subpatch, rejecting folded or degenerate geometry. +pub(super) fn mesh_subpatch_transform(subpatch: &MeshSubpatch) -> Option { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + let determinant = transform.matrix2.determinant(); + (determinant.is_finite() && determinant != 0.).then_some(transform) +} + +/// Returns the local clip and paint inflation needed to hide gaps around a transformed subpatch. +fn mesh_subpatch_inflation(subpatch_to_scene: DAffine2) -> (f64, f64) { + let (_, smallest_scale) = singular_values(subpatch_to_scene); + let clip_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { + (1. / smallest_scale).min(MESH_MAXIMUM_CLIP_INFLATION) + } else { + 0. + }; + + (clip_inflation, clip_inflation * 2.) +} + +// ======================== +// Vello brush construction +// ======================== + +/// Builds a Vello linear gradient brush from sRGBA8 color stops. +fn vello_linear_gradient(start: DVec2, end: DVec2, stop_values: impl IntoIterator) -> peniko::Brush { + let mut stops = peniko::ColorStops::new(); + for (offset, color) in stop_values { + stops.push(peniko::ColorStop { + offset, + color: peniko::color::DynamicColor::from_alpha_color(color.to_peniko_color()), + }); + } + + peniko::Brush::Gradient(peniko::Gradient { + kind: peniko::LinearGradientPosition { + start: kurbo::Point::new(start.x, start.y), + end: kurbo::Point::new(end.x, end.y), + } + .into(), + stops, + extend: peniko::Extend::Pad, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }) +} + +/// Returns brush transforms that preserve horizontal and vertical gradient bands when the subpatch is sheared. +fn vello_subpatch_brush_transforms(subpatch_to_device: DAffine2) -> Option<(kurbo::Affine, kurbo::Affine)> { + if !transform_is_invertible(subpatch_to_device) { + return None; + } + + let device_to_subpatch = subpatch_to_device.inverse(); + let horizontal_gradient_to_device = gradient_placement(subpatch_to_device, vector_types::gradient::GradientForm::Linear); + + let vertical_axis = subpatch_to_device.matrix2.y_axis; + let vertical_band_normal = subpatch_to_device.matrix2.x_axis.perp(); + let vertical_line = if vertical_band_normal.length_squared() > 0. { + vertical_axis.project_onto(vertical_band_normal) + } else { + vertical_axis + }; + let vertical_gradient_to_device = DAffine2 { + matrix2: DMat2::from_cols(vertical_line.perp(), vertical_line), + translation: subpatch_to_device.translation, + }; + + Some(( + kurbo::Affine::new((device_to_subpatch * horizontal_gradient_to_device).to_cols_array()), + kurbo::Affine::new((device_to_subpatch * vertical_gradient_to_device).to_cols_array()), + )) +} + +struct VelloSubpatchBrushes { + top_color: peniko::Brush, + bottom_color: peniko::Brush, + color_weight: peniko::Brush, +} + +/// Builds a vertical Vello alpha mask that approximates a scalar function. +fn vello_vertical_mask(func: &impl Fn(f32) -> f32, start: f32, end: f32) -> peniko::Brush { + let remap_offset = |value: f32| (value - start) / (end - start); + let error = |a: f32, b: f32| (a - b).abs(); + let stops = linear_approximation_points(func, &error, start, end, 0).into_iter().map(|(v, alpha)| { + ( + remap_offset(v), + SRGBA8 { + red: 255, + green: 255, + blue: 255, + alpha: (alpha.clamp(0., 1.) * 255.).round() as u8, + }, + ) + }); + vello_linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), stops) +} + +/// Returns the weight the color pass blends a region's two edge rows with, as a function of v. +/// +/// It projects the color curve at the region's horizontal midpoint onto the line between its edge colors, so however +/// unevenly a color space paces its path along v, the blend follows that pacing and only has to cover the deviation +/// off that line. The subdivision's error model reads the same weight as the brush that paints the region, so the +/// refinement never pays for a coarser approximation than it actually draws. +fn subpatch_color_weight(patch_evaluator: &MeshPatchEvaluator, uv_min: Vec2, uv_max: Vec2) -> impl Fn(f32) -> f32 + use<'_> { + let center_u = (uv_min.x + uv_max.x) / 2.; + let top_center_color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, uv_min.y)).truncate(); + let bottom_center_color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, uv_max.y)).truncate(); + let color_axis = top_center_color - bottom_center_color; + let color_axis_length_squared = color_axis.length_squared(); + + move |v| { + if color_axis_length_squared > f32::EPSILON { + let color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, v)).truncate(); + ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) + } else { + (uv_max.y - v) / (uv_max.y - uv_min.y) + } + } +} + +/// Builds the opaque RGB approximation for one subpatch. +fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch) -> VelloSubpatchBrushes { + let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); + let remap_offset = |value: f32, start: f32, end: f32| (value - start) / (end - start); + + // Preserve each cubic horizontal RGB edge with adaptive gradient stops. Alpha is applied after the RGB field is complete. + let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { + let curve = |u| Vec4::from_array(patch_evaluator.evaluate_color(u, v)); + let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + let stops = linear_approximation_points(&curve, &error, uv_min.x, uv_max.x, 0).into_iter().map(|(u, mut color)| { + color.w = 1.; + (remap_offset(u, uv_min.x, uv_max.x), gamma_color_to_srgba8(color.to_array())) + }); + vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) + }); + + let color_weight_func = subpatch_color_weight(patch_evaluator, uv_min, uv_max); + let color_weight = vello_vertical_mask(&color_weight_func, uv_min.y, uv_max.y); + + VelloSubpatchBrushes { + top_color, + bottom_color, + color_weight, + } +} + +/// Builds an opaque grayscale approximation of a subpatch's alpha field. +fn vello_subpatch_alpha_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch) -> VelloSubpatchBrushes { + let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); + let remap_offset = |value: f32| (value - uv_min.x) / (uv_max.x - uv_min.x); + let opaque_grayscale = |alpha: f32| { + let alpha = alpha.clamp(0., 1.); + gamma_color_to_srgba8([alpha, alpha, alpha, 1.]) + }; + + // This matches the color approximation used to decide adaptive subdivision: preserve the + // horizontal edge curves, then interpolate them linearly in the local v direction. + let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { + let curve = |u| patch_evaluator.evaluate_color(u, v)[3]; + let error = |a: f32, b: f32| (a - b).abs(); + let stops = linear_approximation_points(&curve, &error, uv_min.x, uv_max.x, 0) + .into_iter() + .map(|(u, alpha)| (remap_offset(u), opaque_grayscale(alpha))); + vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) + }); + let color_weight = vello_vertical_mask(&|v| 1. - v, 0., 1.); + + VelloSubpatchBrushes { + top_color, + bottom_color, + color_weight, + } +} + +// ================= +// Vello compositing +// ================= + +/// Paints `brush` through `mask` into an isolated source-over layer. +fn render_vello_masked_brush( + scene: &mut Scene, + subpatch_to_scene: kurbo::Affine, + paint_rect: &kurbo::Rect, + brush: &peniko::Brush, + brush_transform: kurbo::Affine, + mask: &peniko::Brush, + mask_transform: kurbo::Affine, +) { + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., subpatch_to_scene, paint_rect); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, mask, Some(mask_transform), paint_rect); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + subpatch_to_scene, + paint_rect, + ); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, brush, Some(brush_transform), paint_rect); + scene.pop_layer(); + scene.pop_layer(); +} + +/// Renders the weighted top and bottom brushes into an inflated subpatch. +fn render_vello_subpatch_brushes(scene: &mut Scene, subpatch: &MeshSubpatch, parent_transform: DAffine2, brushes: VelloSubpatchBrushes) { + let Some(subpatch_to_parent) = mesh_subpatch_transform(subpatch) else { return }; + + let subpatch_to_device = parent_transform * subpatch_to_parent; + let Some((horizontal_brush_transform, vertical_brush_transform)) = vello_subpatch_brush_transforms(subpatch_to_device) else { + return; + }; + let subpatch_to_scene = kurbo::Affine::new(subpatch_to_device.to_cols_array()); + let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch_to_device); + let clip_rect = kurbo::Rect::new(-clip_inflation, -clip_inflation, 1. + clip_inflation, 1. + clip_inflation); + let paint_rect = kurbo::Rect::new(-paint_inflation, -paint_inflation, 1. + paint_inflation, 1. + paint_inflation); + + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., subpatch_to_scene, &clip_rect); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, &brushes.bottom_color, Some(horizontal_brush_transform), &paint_rect); + render_vello_masked_brush( + scene, + subpatch_to_scene, + &paint_rect, + &brushes.top_color, + horizontal_brush_transform, + &brushes.color_weight, + vertical_brush_transform, + ); + scene.pop_layer(); +} + +/// Renders the opaque RGB field of one adaptively subdivided patch. +pub(super) fn render_vello_subpatch_color(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2) { + let brushes = vello_subpatch_color_brushes(patch_evaluator, subpatch); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); +} + +/// Adds one inflated, opaque grayscale subpatch to the mesh-wide luminance mask. +pub(super) fn render_vello_subpatch_alpha(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2) { + let brushes = vello_subpatch_alpha_brushes(patch_evaluator, subpatch); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); +} + +// ============ +// 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_evaluator(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 normalized patch-bounding-box space so patch translation and axis-aligned scaling do not consume PNG channel precision. + let unit_to_patch_bbox = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); + let unit_to_output = self.parent_transform * self.mesh_transform * unit_to_patch_bbox; + let (_, smallest_output_scale) = singular_values(unit_to_output); + if !smallest_output_scale.is_finite() || smallest_output_scale <= f64::EPSILON { + return; + } + + let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &unit_to_patch_bbox, &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 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_SIZE / smallest_output_scale; + patch_boundary_path.apply_affine(Affine::new(unit_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 * unit_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, region, &v_alpha_mask_ids); + } + + fn collect_transparency_field( + &mut self, + render: &mut SvgRender, + patch: &MeshPatch, + patch_unique_id: u64, + patch_transform: String, + map_region: [f64; 4], + v_alpha_mask_ids: &[String], + ) -> Option<()> { + let mesh_transparency_field = self.mesh_transparency_field.as_deref_mut()?; + let patch_evaluator = self.mesh_evaluator.patch_evaluator(patch.index)?; + let [map_x, map_y, map_width, map_height] = map_region; + + // 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(()) + } +} + +#[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.patch_evaluators() { + 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 9ba8099e34..72ec21c8f1 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 d15d4b6a73..d91e6af8bb 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -3,12 +3,15 @@ extern crate log; pub mod gradient; pub mod math; +pub mod mesh_gradient; pub mod subpath; 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, RectExt}; pub use subpath::Subpath; pub use vector::Vector; 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 0000000000..637786bf99 --- /dev/null +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -0,0 +1,1456 @@ +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, ParamCurve, PathSeg}; + +use crate::{ + Vector, + gradient::{GradientInterpolation, GradientSpace, color_from_gradient_space_channels, gradient_space_channels}, + subpath::{BezierHandles, pathseg_points}, + vector::{ + PointId, SegmentId, StrokeId, + algorithms::util::pathseg_tangent, + misc::{HandleId, HandleType, point_to_dvec2}, + }, +}; + +#[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, +} + +/// 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(); + + 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(self.corners, self.edges, 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 + } +} + +/// 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), + } + } +} + +/// 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) +} + +/// 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, +} + +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]), + StrokeId::ZERO, + ); + horizontal_edges.push(segment_id); + } + } + + let mut vertical_edges = Vec::with_capacity((corner_rows - 1) * corner_columns); + for row in 0..(corner_rows - 1) { + for column in 0..corner_columns { + let start_index = row * corner_columns + column; + let end_index = start_index + corner_columns; + + let segment_id = 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]), + StrokeId::ZERO, + ); + vertical_edges.push(segment_id); + } + } + + let corner_colors = (0..corner_rows) + .flat_map(|row| { + (0..corner_columns).map(move |column| { + let luminance = (row + column).is_multiple_of(2) as u8 as f32; + Color::from_luminance(luminance) + }) + }) + .collect(); + + 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)?, + }) + } + + /// 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.path_segment_from_id(top_edge_id)?, + self.mesh_geometry.path_segment_from_id(bottom_edge_id)?, + self.mesh_geometry.path_segment_from_id(left_edge_id)?, + self.mesh_geometry.path_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))) + } + + // TODO: Research the way to handle polar color spaces for mesh gradient + /// Returns a new `MeshGradientEvaluator` whose Hermite color field is expressed in `space`. + pub fn evaluator(&self, space: GradientSpace, interpolation: GradientInterpolation) -> Option { + if space.is_polar() { + return None; + } + MeshGradientEvaluator::new(self, space, interpolation) + } + + /// 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); + + 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; + 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; + 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, + } + + 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)?; + 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] = self.mesh_geometry.points_from_id(segment_id)?; + let segment = self.mesh_geometry.path_segment_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.evaluate_color(patch_index, 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), StrokeId::ZERO); + 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), StrokeId::ZERO); + 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), StrokeId::ZERO); + 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); + + 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 first_segment = self.mesh_geometry.path_segment_from_id(first_segment_id)?.to_cubic(); + let second_segment = self.mesh_geometry.path_segment_from_id(second_segment_id)?.to_cubic(); + let [start_point_id, _] = self.mesh_geometry.points_from_id(first_segment_id)?; + let [_, end_point_id] = self.mesh_geometry.points_from_id(second_segment_id)?; + + // 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, StrokeId::ZERO); + 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)); + + Some(()) + } +} + +#[derive(Clone, Copy)] +struct MeshCornerDerivatives { + u: Vec4, + v: Vec4, +} + +#[derive(Clone, Copy)] +enum MeshPatchInterpolation { + Stepped, + Linear, + Smooth { + /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_slopes: [MeshCornerDerivatives; 4], + /// Linear length of between each corner. [top, bottom, left, right] + lengths: [f32; 4], + /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. + bezier_control_points: [[Vec4; 4]; 4], + }, +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +pub struct MeshPatchEvaluator { + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], + /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] + colors: [Vec4; 4], + /// Color space used by `colors` and `color_slopes`. + space: GradientSpace, + /// Color interpolation method. + interpolation: MeshPatchInterpolation, +} + +impl MeshPatchEvaluator { + /// 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.interpolation { + MeshPatchInterpolation::Stepped => top_left_color.to_array(), + MeshPatchInterpolation::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() + } + MeshPatchInterpolation::Smooth { color_slopes, lengths, .. } => { + let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { + let t_power_2 = t * t; + let t_power_3 = t_power_2 * t; + + let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; + let h2 = -2. * t_power_3 + 3. * t_power_2; + let h3 = t_power_3 - 2. * t_power_2 + t; + let h4 = t_power_3 - t_power_2; + + ma * h3 + a * h1 + b * h2 + mb * h4 + }; + + let [top_length, bottom_length, left_length, right_length] = lengths; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = color_slopes; + + std::array::from_fn(|channel| { + let top_color_interpolated = hermite( + top_left_color[channel], + top_left_color_slope.u[channel] * top_length, + top_right_color[channel], + top_right_color_slope.u[channel] * top_length, + u, + ); + let bottom_color_interpolated = hermite( + bottom_left_color[channel], + bottom_left_color_slope.u[channel] * bottom_length, + bottom_right_color[channel], + bottom_right_color_slope.u[channel] * bottom_length, + u, + ); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); + hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) + }) + } + } + } + + /// 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.space == GradientSpace::RgbGamma { + channels + } else { + color_from_gradient_space_channels(channels, self.space).to_gamma_srgb_channels() + } + } + + /// Evaluates the interpolated position using a bilinearly blended Coons patch. + pub fn evaluate_position(&self, u: f64, v: f64) -> DVec2 { + let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; + let [top_left, top_right, bottom_left, bottom_right] = self.corners; + + let top_u_pos = point_to_dvec2(top_seg.eval(u)); + let bottom_u_pos = point_to_dvec2(bottom_seg.eval(u)); + let left_v_pos = point_to_dvec2(left_seg.eval(v)); + let right_v_pos = point_to_dvec2(right_seg.eval(v)); + + let s_c = (1. - v) * top_u_pos + v * bottom_u_pos; + let s_d = (1. - u) * left_v_pos + u * right_v_pos; + let s_b = top_left * (1. - u) * (1. - v) + top_right * u * (1. - v) + bottom_left * (1. - u) * v + bottom_right * u * v; + + s_c + s_d - s_b + } + + /// 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.corners, self.edges, 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_bicubic_bezier_row(&self, row: usize, u: f32) -> Option { + let MeshPatchInterpolation::Smooth { bezier_control_points, .. } = &self.interpolation else { + return None; + }; + let &[a, b, c, d] = bezier_control_points.get(row)?; + let one_minus_u = 1. - u; + Some(a * one_minus_u.powi(3) + b * (3. * u * one_minus_u.powi(2)) + c * (3. * u.powi(2) * one_minus_u) + d * u.powi(3)) + } +} + +/// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. +fn bicubic_bezier_control_net(colors: &[Vec4; 4], color_slopes: &[MeshCornerDerivatives; 4], lengths: &[f32; 4]) -> [[Vec4; 4]; 4] { + let [top_length, bottom_length, left_length, right_length] = *lengths; + 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_slopes; + + 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] * left_length, + bottom_left_color[channel], + bottom_left_color_slope.v[channel] * left_length, + ), + Vec4::new(top_left_color_slope.u[channel] * top_length, 0., bottom_left_color_slope.u[channel] * bottom_length, 0.), + Vec4::new( + top_right_color[channel], + top_right_color_slope.v[channel] * right_length, + bottom_right_color[channel], + bottom_right_color_slope.v[channel] * right_length, + ), + Vec4::new(top_right_color_slope.u[channel] * top_length, 0., bottom_right_color_slope.u[channel] * bottom_length, 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); + + 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]))) +} + +/// Struct for evaluating color for subpatch corners. +/// The main purpose is to prevent duplicated calculation of the slopes for hermite interpolation for each subpatch. +#[derive(Clone)] +pub struct MeshGradientEvaluator { + /// List of required data for color interpolation, row major order. + patches: Vec, + space: GradientSpace, + interpolation: GradientInterpolation, +} + +impl MeshGradientEvaluator { + pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Option { + let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); + if corner_rows < 2 || corner_columns < 2 { + return None; + } + 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 None; + } + + let corner_positions: Vec = mesh_gradient + .corner_points + .values + .iter() + .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) + .collect::>()?; + + let colors: Vec = mesh_gradient + .corner_colors + .values + .iter() + .map(|&color| Vec4::from_array(gradient_space_channels(color, space))) + .collect(); + + // Calculate the slope of the `curr_index` corner by FDM. The slope is derived from the linear distance from the previous/next corners. + let calculate_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let prev_color = colors[prev_index]; + let curr_color = colors[curr_index]; + let next_color = colors[next_index]; + + let [prev_pos, curr_pos, next_pos] = [prev_index, curr_index, next_index].map(|index| corner_positions[index]); + let prev_distance = curr_pos.distance(prev_pos) as f32; + let next_distance = next_pos.distance(curr_pos) as f32; + + let backward_diff = (prev_distance > f32::EPSILON).then(|| (curr_color - prev_color) / prev_distance); + let forward_diff = (next_distance > f32::EPSILON).then(|| (next_color - curr_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 sample_index = |row: isize, column: isize| -> usize { + let clamped_column = column.clamp(0, corner_columns as isize - 1) as usize; + let clamped_row = row.clamp(0, corner_rows as isize - 1) as usize; + clamped_row * corner_columns + clamped_column + }; + + let corner_slopes = (interpolation == GradientInterpolation::Smooth).then(|| { + let mut slopes = Vec::with_capacity(corner_rows * corner_columns); + for row in 0..corner_rows as isize { + for col in 0..corner_columns as isize { + let curr_index = sample_index(row, col); + let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + slopes.push(MeshCornerDerivatives { u, v }); + } + } + slopes + }); + + let mut patch_color_data = Vec::with_capacity(patch_rows.checked_mul(patch_columns)?); + for row in 0..patch_rows { + for column in 0..patch_columns { + let patch = mesh_gradient.patch(row, column)?; + 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]; + let patch_colors = corner_indices.map(|index| colors[index]); + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; + + let interpolation = match interpolation { + GradientInterpolation::Stepped => MeshPatchInterpolation::Stepped, + GradientInterpolation::Linear => MeshPatchInterpolation::Linear, + GradientInterpolation::Smooth => { + let corner_slopes = corner_slopes.as_ref().expect("Smooth interpolation must have color slopes"); + let color_slopes = corner_indices.map(|index| corner_slopes[index]); + let lengths = [ + top_left_pos.distance(top_right_pos) as f32, + bottom_left_pos.distance(bottom_right_pos) as f32, + top_left_pos.distance(bottom_left_pos) as f32, + top_right_pos.distance(bottom_right_pos) as f32, + ]; + let bezier_control_points = bicubic_bezier_control_net(&patch_colors, &color_slopes, &lengths); + MeshPatchInterpolation::Smooth { + color_slopes, + lengths, + bezier_control_points, + } + } + }; + + patch_color_data.push(MeshPatchEvaluator { + corners: patch.corners, + edges: patch.edges, + colors: patch_colors, + space, + interpolation, + }); + } + } + + Some(Self { + patches: patch_color_data, + space, + interpolation, + }) + } + + pub fn interpolation_method(&self) -> GradientInterpolation { + self.interpolation + } + + pub fn space(&self) -> GradientSpace { + self.space + } + + fn evaluate_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { + self.patches[patch_index].evaluate_color(u, v) + } + + /// Returns the cached evaluators in row-major patch order. + pub fn patch_evaluators(&self) -> impl Iterator { + self.patches.iter() + } + + pub fn patch_evaluator(&self, patch_index: usize) -> Option<&MeshPatchEvaluator> { + self.patches.get(patch_index) + } +} + +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 { + core_types::bounds::BoundingBox::bounding_box(&self.mesh_geometry, transform, include_stroke) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + core_types::bounds::BoundingBox::thumbnail_bounding_box(&self.mesh_geometry, transform, include_stroke) + } +} + +/// 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.)) +} + +/// Returns Jacobian matrix of the UV position in a single Coons patch. +fn position_jacobian(corners: [DVec2; 4], edges: [PathSeg; 4], u: f64, v: f64) -> DMat2 { + let [top, bottom, left, right] = edges; + let [top_left, top_right, bottom_left, bottom_right] = corners; + + let top_u_pos = point_to_dvec2(top.eval(u)); + let bottom_u_pos = point_to_dvec2(bottom.eval(u)); + let left_v_pos = point_to_dvec2(left.eval(v)); + let right_v_pos = point_to_dvec2(right.eval(v)); + + let top_bottom_derivative_u = (1. - v) * pathseg_tangent(top, u) + v * pathseg_tangent(bottom, u); + let left_right_derivative_u = right_v_pos - left_v_pos; + let top_bottom_derivative_v = bottom_u_pos - top_u_pos; + let left_right_derivative_v = (1. - u) * pathseg_tangent(left, v) + u * pathseg_tangent(right, v); + + let bilinear_derivative_u = (1. - v) * (top_right - top_left) + v * (bottom_right - bottom_left); + let bilinear_derivative_v = (1. - u) * (bottom_left - top_left) + u * (bottom_right - top_right); + + let derivative_u = top_bottom_derivative_u + left_right_derivative_u - bilinear_derivative_u; + let derivative_v = top_bottom_derivative_v + left_right_derivative_v - bilinear_derivative_v; + + DMat2::from_cols(derivative_u, derivative_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 { + MeshPatchEvaluator { + corners, + edges, + colors: [Vec4::ZERO; 4], + space: GradientSpace::RgbGamma, + interpolation: MeshPatchInterpolation::Linear, + } + } + + 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() -> 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)), + ]; + 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 = [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4]; + let lengths = [1.; 4]; + let evaluator = MeshPatchEvaluator { + corners: [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], + edges: line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), + colors, + space: GradientSpace::RgbGamma, + interpolation: MeshPatchInterpolation::Smooth { + color_slopes, + lengths, + bezier_control_points: bicubic_bezier_control_net(&colors, &color_slopes, &lengths), + }, + }; + + 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_evaluator(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_evaluator(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_evaluator(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_evaluator(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 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(evaluator.edges[0].eval(t))); + assert_position(evaluator.evaluate_position(t, 1.), point_to_dvec2(evaluator.edges[1].eval(t))); + assert_position(evaluator.evaluate_position(0., t), point_to_dvec2(evaluator.edges[2].eval(t))); + assert_position(evaluator.evaluate_position(1., t), point_to_dvec2(evaluator.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 jacobian = position_jacobian(corners, edges, 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 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(evaluator.corners, evaluator.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.path_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.path_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/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 63f9b87650..04686239b0 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -941,6 +941,15 @@ impl Vector { self.segment_points_from_id(id).map(|(_, _, bezier)| bezier) } + /// Tries to convert a segment with the specified id to a [`PathSeg`], returning None if the id is invalid. + pub fn path_segment_from_id(&self, id: SegmentId) -> Option { + let segment_index = self.segment_domain.id_to_index(id)?; + let start_index = *self.segment_domain.start_point().get(segment_index)?; + let end_index = *self.segment_domain.end_point().get(segment_index)?; + let handles = *self.segment_domain.handles().get(segment_index)?; + Some(self.path_segment_from_index(start_index, end_index, handles)) + } + /// Tries to convert a segment with the specified id to the start and end points and a [`Bezier`], returning None if the id is invalid. pub fn segment_points_from_id(&self, id: SegmentId) -> Option<(PointId, PointId, Bezier)> { Some(self.segment_points_from_index(self.segment_domain.id_to_index(id)?)) diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 01168afec9..12b59626bd 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -10,7 +10,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. @@ -939,6 +939,7 @@ pub async fn into_group + 'n>( List>, List, List, + List, List, List, Item, // TODO: Remove this diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 3761fef610..0e3fa69789 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -58,7 +58,7 @@ pub mod subpath { } pub mod gradient { - pub use vector_types::{Gradient, GradientStop}; + pub use vector_types::{Gradient, GradientStop, MeshGradient}; } pub mod transform { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 9a8b330c67..12b7d45c6b 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. @@ -1499,6 +1499,12 @@ fn gradient_stretch( gradient } +/// Constructs a mesh gradient value composed of a grid of patches defined by colored corners and curved boundary segments. +#[node_macro::node(category("Value"))] +fn mesh_gradient_value(_: impl Ctx, _primary: (), mesh_gradient: Item) -> Item { + mesh_gradient +} + /// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. Colors between stops interpolate in the gradient's `gradient_space` color space. #[node_macro::node(category("Color"))] fn evaluate_gradient( diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 870b98a117..58825f3b84 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -266,9 +266,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(), // Normalized to GraphicList above Graphic::Graphic(_) => Vec::new(), } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 48be2977be..af395f1b35 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -21,8 +21,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::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath}; use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; @@ -35,6 +34,7 @@ use vector_types::vector::misc::{ use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::vector::{PointDomain, RegionDomain}; +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 +85,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 { @@ -314,19 +336,23 @@ async fn fill( _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; // 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(); - // 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(), @@ -334,32 +360,24 @@ where ), _ => (false, false), }; + 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_transform = needs_transform.then(|| { + 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(paint_target_bounds(&mut content)) + }); + let stamped_mesh_transform = needs_mesh_transform.then(|| { + if _has_mesh_transform { + return _mesh_transform; } - initial_gradient_transform_for_bounding_box([min, max]) + initial_mesh_gradient_transform_for_bounding_box(paint_target_bounds(&mut content)) }); match &mut paint { @@ -367,7 +385,7 @@ where 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 +395,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; }