diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..7566f78270 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, Control], action_dispatch=MeshGradientToolMessage::PointerMove { constrain_axis: Shift, lock_angle: Control }), + 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 908e883892..f0966637b7 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -23,7 +23,7 @@ use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{DashPattern, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{DashPattern, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType, MeshGradient, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Context, Graphic}; use std::any::Any; @@ -518,6 +518,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.identifier(), Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), + Self::MeshGradient(list) => list.identifier(), Self::Text(list) => list.identifier(), } } @@ -534,6 +535,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.layout_with_breadcrumb(data), Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), + Self::MeshGradient(list) => list.layout_with_breadcrumb(data), Self::Text(list) => list.layout_with_breadcrumb(data), } } @@ -740,6 +742,21 @@ impl TableItemLayout for Gradient { } } +impl TableItemLayout for MeshGradient { + fn type_name() -> &'static str { + "MeshGradient" + } + fn identifier(&self) -> String { + //FIXME: not implemented yet + format!("MeshGradient ({})", 1) + } + fn value_page(&self, _data: &mut LayoutData) -> Vec { + //FIXME: not implemented yet + let widgets = vec![TextLabel::new("FIXME: MeshGradient cannot be displayed here").widget_instance()]; + vec![LayoutGroup::row(widgets)] + } +} + impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number (f64)" diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index b61c33de82..ff9fa6c55c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2674,7 +2674,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte let mut row = vec![TextLabel::new("").widget_instance()]; add_blank_assist(&mut row); - let entries = [GradientType::Linear, GradientType::Radial] + let entries = [GradientType::Linear, GradientType::Radial, GradientType::Mesh] .iter() .map(|&grad_type| { RadioEntryData::new(format!("{:?}", grad_type)) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 57315f58d3..48fa5376a1 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -69,7 +69,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/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 018fad9125..09b08e032e 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -360,6 +360,17 @@ 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 { + let target_input = gradient_chain_target_input(layer, network_interface); + let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?; + + network_interface + .upstream_flow_back_from_nodes(vec![walk_from], &[], FlowType::HorizontalFlow) + .take_while(|node_id| !network_interface.is_layer(node_id, &[])) + .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER))) +} + /// Get the current fill of a layer from the closest "Fill" node. pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))?; 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/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 940d7e6552..22bee17871 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -253,7 +253,7 @@ impl LayoutHolder for GradientTool { .into() }), ]) - .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) + .selected_index(Some(self.options.gradient_type as u32)) .widget_instance(); // Display priority: the selected layer's stops, then any user-customized tool default, then the working colors diff --git a/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..50e1478979 --- /dev/null +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -0,0 +1,782 @@ +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::tool::common_functionality::auto_panning::AutoPanning; +use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_mesh_gradient_value_node_id; +use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; +use graph_craft::document::NodeId; +use graph_craft::document::value::TaggedValue; +use graphene_std::NodeInputDecleration; +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::{GradientSpreadMethod, GradientType, GradientUI}; +use graphene_std::vector::{HandleId, MeshGradient, SegmentId}; +use graphene_std::{ATTR_TRANSFORM, Graphic}; +use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; + +#[derive(Default, ExtractField)] +pub struct MeshGradientTool { + fsm_state: MeshGradientToolFsmState, + data: MeshGradientToolData, +} + +#[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, + WorkingColorChanged, + + // Tool-specific messages + DeleteEdge, + DoubleClick, + InsertStop, + PointerDown, + PointerMove { constrain_axis: Key, lock_angle: Key }, + PointerOutsideViewport { constrain_axis: Key, lock_angle: Key }, + PointerUp, + StartTransactionForColorStop, + CommitTransactionForColorStop, + CloseStopColorPicker, + UpdateStopColor { color: Color }, + UpdateStops { stops: GradientUI }, + UpdateOptions { options: MeshGradientOptionsUpdate }, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] +pub enum MeshGradientOptionsUpdate { + Type(GradientType), + ReverseStops, + ReverseDirection, + SetSpreadMethod(GradientSpreadMethod), +} + +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 { + _ => {} + }, + 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.gradient.set_corner_color(corner_index, color).is_some() + { + responses.add(NodeGraphMessage::SetInputValue { + node_id: selected_mesh.source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(selected_mesh.gradient.clone()), + }); + + responses.add(PropertiesPanelMessage::Refresh); + responses.add(OverlaysMessage::Draw); + } + } + _ => { + self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); + } + } + } + + fn actions(&self) -> ActionList { + let common = actions!(MeshGradientToolMessageDiscriminant; + PointerDown, + PointerUp, + PointerMove, + DoubleClick, + DeleteEdge, + Abort, + ); + common + } +} + +impl LayoutHolder for MeshGradientTool { + fn layout(&self) -> Layout { + Layout::default() + } +} + +#[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, + mesh_index: usize, + gradient: MeshGradient, + mesh_to_document: DAffine2, + source_node_id: NodeId, + target: MeshGradientTarget, +} + +#[derive(Clone, Debug, PartialEq)] +enum MeshGradientTarget { + Corner { + corner_index: usize, + initial_mouse: DVec2, + initial_corner: DVec2, + }, + Segment { + segment_id: SegmentId, + initial_mouse: DVec2, + initial_handles: [DVec2; 2], + }, + Handle { + handle_id: HandleId, + initial_mouse: DVec2, + initial_handle: DVec2, + }, +} + +impl ToolTransition for MeshGradientTool { + fn event_to_message_map(&self) -> EventToMessageMap { + EventToMessageMap { + tool_abort: Some(MeshGradientToolMessage::Abort.into()), + selection_changed: Some(MeshGradientToolMessage::SelectionChanged.into()), + working_color_changed: Some(MeshGradientToolMessage::WorkingColorChanged.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, +} + +impl Fsm for MeshGradientToolFsmState { + type ToolData = MeshGradientToolData; + type ToolOptions = (); + + 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(fill) = metadata.layer_fill_attributes.get(&layer) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + for graphic in fill.iter_element_values() { + let Graphic::MeshGradient(meshes) = graphic else { + continue; + }; + + for index in 0..meshes.len() { + let Some(mesh) = meshes.element(index) else { + continue; + }; + + let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + 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 || selected_mesh.mesh_index != index { + 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 + && selected_mesh.mesh_index == index + && 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); + + 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 @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { + let Some((source_node_id, gradient)) = tool_data.selected_mesh.as_mut().and_then(|selected_mesh| { + let segment_id = match selected_mesh.target { + MeshGradientTarget::Segment { segment_id, .. } => segment_id, + _ => return None, + }; + selected_mesh.gradient.remove_edge(segment_id)?; + Some((selected_mesh.source_node_id, selected_mesh.gradient.clone())) + }) else { + return state; + }; + + responses.add(DocumentMessage::StartTransaction); + responses.add(NodeGraphMessage::SetInputValue { + node_id: source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(gradient), + }); + 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 mesh_to_viewport = document.metadata().document_to_viewport * selected_mesh.mesh_to_document; + + match selected_mesh.target { + // Display color picker when the mesh corner color gizmo is double clicked + MeshGradientTarget::Corner { corner_index, .. } => { + let Some(corner) = selected_mesh.gradient.corners().find(|corner| corner.index == corner_index) else { + return self; + }; + + tool_data.color_picker_editing_color_stop = Some(corner.index); + + let position = mesh_to_viewport.transform_point2(corner.position).into(); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + } + MeshGradientTarget::Segment { segment_id, .. } => { + let Some(segment) = selected_mesh.gradient.edges().find(|edge| edge.segment_id == segment_id) else { + return self; + }; + let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + let t = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + if selected_mesh.gradient.insert_grid_line(segment.segment_id, t).is_none() { + return self; + } + + responses.add(DocumentMessage::StartTransaction); + responses.add(NodeGraphMessage::SetInputValue { + node_id: selected_mesh.source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(selected_mesh.gradient.clone()), + }); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + } + _ => {} + }; + + 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(fill) = metadata.layer_fill_attributes.get(&layer) else { + continue; + }; + + let Some(source_node_id) = get_upstream_mesh_gradient_value_node_id(layer, &document.network_interface) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + for graphic in fill.iter_element_values() { + let Graphic::MeshGradient(meshes) = graphic else { + continue; + }; + + for index in 0..meshes.len() { + let Some(gradient) = meshes.element(index) else { + continue; + }; + + let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + 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, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source_node_id, + target: MeshGradientTarget::Corner { + corner_index: corner.index, + initial_mouse: local_mouse, + initial_corner: corner.position, + }, + }); + + 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); + } + } + + if let Some((handle_id, initial_handle, _)) = closest_handle { + responses.add(DocumentMessage::StartTransaction); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source_node_id, + target: MeshGradientTarget::Handle { + handle_id, + initial_mouse: local_mouse, + initial_handle, + }, + }); + + 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(p1), None) | (None, Some(p1)) => [p1, points.p3], + (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, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source_node_id, + target: MeshGradientTarget::Segment { + segment_id: edge.segment_id, + initial_mouse: local_mouse, + initial_handles: handles, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + } + } + } + + self + } + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }) => { + 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 constrain_or_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 selected_mesh.target { + MeshGradientTarget::Corner { + corner_index, + initial_mouse: initial_local_mouse, + initial_corner, + } => { + let delta = current_local_mouse - initial_local_mouse; + let new_corner_position = constrain_or_snap_local_point(initial_corner, initial_corner + delta); + + selected_mesh.gradient.set_corner_position(corner_index, new_corner_position); + + // FIXME: implement proper setter message + responses.add(NodeGraphMessage::SetInputValue { + node_id: selected_mesh.source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(selected_mesh.gradient.clone()), + }); + + responses.add(OverlaysMessage::Draw); + } + MeshGradientTarget::Segment { + segment_id, + initial_mouse: initial_local_mouse, + initial_handles, + } => { + let snapped_local_mouse = constrain_or_snap_local_point(initial_local_mouse, current_local_mouse); + let delta = snapped_local_mouse - initial_local_mouse; + let handle_start = initial_handles[0] + delta; + let handle_end = initial_handles[1] + delta; + + selected_mesh.gradient.set_edge_handles(segment_id, BezierHandles::Cubic { handle_start, handle_end }); + + responses.add(NodeGraphMessage::SetInputValue { + node_id: selected_mesh.source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(selected_mesh.gradient.clone()), + }); + + responses.add(OverlaysMessage::Draw); + } + MeshGradientTarget::Handle { + handle_id, + initial_mouse, + initial_handle, + } => { + let delta = current_local_mouse - initial_mouse; + let new_handle_position = constrain_or_snap_local_point(initial_handle, initial_handle + delta); + + selected_mesh.gradient.set_handle_position(handle_id, new_handle_position); + + responses.add(NodeGraphMessage::SetInputValue { + node_id: selected_mesh.source_node_id, + input_index: graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(selected_mesh.gradient.clone()), + }); + + responses.add(OverlaysMessage::Draw); + } + }; + + // Auto-panning + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.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, lock_angle }) => { + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.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::LmbDrag, "Edit 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 6fbb206d8b..79a224c91d 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -367,6 +367,7 @@ pub enum ToolType { Eyedropper, Fill, Gradient, + MeshGradient, // Vector tool group Path, @@ -417,6 +418,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 @@ -469,6 +471,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, @@ -498,6 +501,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_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 81843a6f6d..6c04470de9 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -707,6 +707,7 @@ impl EditorWrapper { #[wasm_bindgen(js_name = updateGradientStopColor)] pub fn update_gradient_stop_color(&self, color: SRGBA8) { self.dispatch(GradientToolMessage::UpdateStopColor { color: Color::from(color) }); + self.dispatch(MeshGradientToolMessage::UpdateStopColor { color: Color::from(color) }); } /// Start a new undo transaction for gradient stop color editing diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index cd28de5d4b..33e7dc5129 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -15,7 +15,7 @@ use graphene_application_io::resource::ResourceId; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; -use graphic_types::vector_types::vector::style::Gradient; +use graphic_types::vector_types::vector::style::{Gradient, MeshGradient}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::RenderMetadata; @@ -96,6 +96,8 @@ macro_rules! tagged_value { #[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] Gradient(Gradient), + /// Stored compactly as a `MeshGradient`, materializing as an `Item` at runtime. + MeshGradient(MeshGradient), /// Stored compactly as a `Vec`, materializes as the single-value `Item` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "BrushStrokeTable")] @@ -141,6 +143,7 @@ macro_rules! tagged_value { Self::F64Array(values) => values.cache_hash(state), Self::Color(color) => color.cache_hash(state), Self::Gradient(stops) => stops.cache_hash(state), + Self::MeshGradient(mesh_gradient) => mesh_gradient.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS @@ -203,6 +206,7 @@ macro_rules! tagged_value { } Self::Color(color) => Box::new(Item::new_from_element(color)), Self::Gradient(stops) => Box::new(Item::new_from_element(stops)), + Self::MeshGradient(mesh_gradient) => Box::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -265,6 +269,7 @@ macro_rules! tagged_value { } Self::Color(color) => Arc::new(Item::new_from_element(color)), Self::Gradient(stops) => Arc::new(Item::new_from_element(stops)), + Self::MeshGradient(mesh_gradient) => Arc::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -293,6 +298,7 @@ macro_rules! tagged_value { Self::F64Array(_) => list!(f64), Self::Color(_) => item!(Color), Self::Gradient(_) => item!(Gradient), + Self::MeshGradient(_) => item!(MeshGradient), Self::BrushStrokes(_) => item!(BrushTrace), // ======================= // AUTO-GENERATED VARIANTS @@ -372,6 +378,7 @@ macro_rules! tagged_value { if name == std::any::type_name::<()>() { return Some(TaggedValue::None) } if name == std::any::type_name::() { return Some(TaggedValue::Color(Color::default())) } if name == std::any::type_name::() { return Some(TaggedValue::Gradient(Gradient::default())) } + if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradient::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::() { return Some(TaggedValue::BrushStrokes(Vec::new())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time @@ -425,6 +432,7 @@ macro_rules! tagged_value { Self::F64Array(values) => format!("F64Array({values:?})"), Self::Color(color) => format!("Color({color:?})"), Self::Gradient(stops) => format!("Gradient({stops:?})"), + Self::MeshGradient(mesh_gradient) => format!("MeshGradient({mesh_gradient:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index b8f95a434f..85970e91b0 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12815475172301479638), NodeId(13251389748338817266), NodeId(7166921994790432021), NodeId(15318519137317483318)] + vec![NodeId(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index c7f0bc5c80..be0088e97c 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -7,7 +7,7 @@ use graph_craft::proto::{NodeConstructor, TypeErasedBox}; use graphene_std::any::DynAnyNode; use graphene_std::brush::brush_stroke::BrushTrace; use graphene_std::extract_xy::XY; -use graphene_std::gradient::Gradient; +use graphene_std::gradient::{Gradient, MeshGradient}; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] use graphene_std::platform_application_io::canvas_utils::CanvasHandle; @@ -40,6 +40,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -48,6 +49,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -97,6 +99,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), @@ -114,6 +117,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -320,6 +324,7 @@ fn node_registry() -> HashMap HashMap, Color, Gradient, + MeshGradient, f64, bool, String, @@ -417,6 +423,7 @@ fn node_registry() -> HashMap HashMap), attribute_value_node!(List), attribute_value_node!(List), + attribute_value_node!(List), attribute_value_node!(List), attribute_value_node!(List>), #[cfg(feature = "gpu")] @@ -571,6 +579,7 @@ fn node_registry() -> HashMap), transform_list_node!(element: Color), transform_list_node!(element: Gradient), + transform_list_node!(element: MeshGradient), ]; node_types.extend(transform_list_rows); let mut map: HashMap> = HashMap::new(); diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index e5197f8e55..03800a68e9 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -10,6 +10,7 @@ use raster_types::{CPU, GPU, Raster}; use std::borrow::Cow; use vector_types::Gradient; pub use vector_types::Vector; +use vector_types::gradient::MeshGradient; /// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)] @@ -23,6 +24,7 @@ pub enum Graphic { RasterGPU(List>), Color(List), Gradient(List), + MeshGradient(List), Text(List), } @@ -234,6 +236,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterCPU(list) => bake_list_transform(list, transform), Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform), + Graphic::MeshGradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform), Graphic::Color(_) => {} } @@ -339,6 +342,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::MeshGradient(self)) + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path = self.attribute::(ATTR_EDITOR_LAYER_PATH, 0).cloned(); @@ -427,6 +436,7 @@ impl Graphic { Graphic::RasterGPU(list) => all_clipped(list), Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), + Graphic::MeshGradient(list) => all_clipped(list), Graphic::Text(list) => all_clipped(list), } } @@ -467,7 +477,8 @@ impl Graphic { } Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -491,7 +502,8 @@ impl Graphic { }), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -509,6 +521,7 @@ impl Graphic { Graphic::Vector(list) => list.is_empty(), Graphic::Color(list) => list.is_empty(), Graphic::Gradient(list) => list.is_empty(), + Graphic::MeshGradient(list) => list.is_empty(), Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), Graphic::Text(list) => list.is_empty(), @@ -526,6 +539,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(list) => list.bounding_box(transform, include_stroke), Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), + Graphic::MeshGradient(list) => list.bounding_box(transform, include_stroke), Graphic::Text(list) => list.bounding_box(transform, include_stroke), } } @@ -539,6 +553,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::MeshGradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), } } @@ -549,11 +564,23 @@ impl RenderComplexity for Graphic { match self { Self::None => 0, Self::Graphic(list) => list.render_complexity(), - Self::Vector(list) => list.render_complexity(), + Self::Vector(list) => { + let element_complexity = list.render_complexity(); + + let paint_complexity = [ATTR_FILL, ATTR_STROKE] + .into_iter() + .filter_map(|attribute| list.iter_attribute_values::>(attribute)) + .flatten() + .map(|paint| paint.render_complexity()) + .fold(0, usize::saturating_add); + + element_complexity.saturating_add(paint_complexity) + } Self::RasterCPU(list) => list.render_complexity(), Self::RasterGPU(list) => list.render_complexity(), Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), + Self::MeshGradient(list) => list.render_complexity(), Self::Text(list) => list.render_complexity(), } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 1113e5816c..6f72314aee 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -138,7 +138,7 @@ impl RenderExt for List { let gradient_id = generate_uuid(); match gradient_type { - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let _ = write!( svg_defs, r#"{}"#, @@ -242,7 +242,7 @@ impl RenderExt for List { format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::None) => format!(r#" {paint_attr}="none""#), - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::MeshGradient(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; @@ -263,7 +263,7 @@ impl RenderExt for List { } /// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID. -/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet. +/// Currently, this function is only used for clipping-based filling and stroking and mesh gradient, not considering tiling yet. fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { let min = bounds.transform_point2(DVec2::ZERO); let max = bounds.transform_point2(DVec2::ONE); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 4367dd53c0..cfc022378f 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,10 +2,8 @@ use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; use core_types::blending::BlendMode; -use core_types::bounds::BoundingBox; -use core_types::bounds::RenderBoundingBox; -use core_types::color::Color; -use core_types::color::SRGBA8; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::color::{Color, SRGBA8}; use core_types::consts::DEFAULT_FONT_SIZE; use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath}; use core_types::math::quad::Quad; @@ -39,7 +37,8 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::GradientSpreadMethod; +use vector_types::gradient::{GradientSpreadMethod, MeshGradient, MeshSubpatch}; +use vello::peniko::color::AlphaColor; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -160,6 +159,13 @@ impl SvgRender { self.svg.push("/>".into()); } } + + pub fn with_transform(&mut self, transform: DAffine2, inner: impl FnOnce(&mut Self)) { + let previous_transform = self.transform; + self.transform *= transform; + inner(self); + self.transform = previous_transform; + } } pub struct SvgRenderOutput { @@ -266,6 +272,46 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } +// FIXME: Use minimum subpatch size in viewport instead +const MESH_MAXIMUM_SUBDIVISIONS_PER_PATCH_PER_AXIS: usize = 32; +const MESH_POSITION_ERROR_TOLERANCE_IN_VIEWPORT: f64 = 2.; +const MESH_COLOR_ERROR_TOLERANCE: f32 = 5. / 255.; +const MESH_BASE_CLIP_INFLATION: f64 = 0.01; +const MESH_MAXIMUM_INFLATION_BUCKET: usize = 8; + +fn mesh_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]), + } +} + +fn mesh_subpatch_inflation_bucket(subpatch: &MeshSubpatch) -> usize { + let [top_left, top_right, bottom_left, _] = subpatch.corners; + let subpatch_transform = DAffine2::from_cols(top_right.position - top_left.position, bottom_left.position - top_left.position, top_left.position); + let (_, smallest_scale) = singular_values(subpatch_transform); + let required_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { + (1. / smallest_scale).max(MESH_BASE_CLIP_INFLATION) + } else { + MESH_BASE_CLIP_INFLATION + }; + + ((required_inflation / MESH_BASE_CLIP_INFLATION).log2().ceil() as usize).min(MESH_MAXIMUM_INFLATION_BUCKET) +} + +fn mesh_inflation_values(bucket: usize) -> (f64, f64, f64, f64) { + let clip_inflation = MESH_BASE_CLIP_INFLATION * 2_f64.powi(bucket as i32); + let paint_inflation = clip_inflation * 2.; + let clip_min = -clip_inflation; + let clip_size = 1. + 2. * clip_inflation; + let paint_min = -paint_inflation; + let paint_size = 1. + 2. * paint_inflation; + (clip_min, clip_size, paint_min, paint_size) +} + /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. @@ -382,7 +428,7 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientType) -> DAffine2 { match gradient_type { GradientType::Radial => transform, - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let axis = transform.matrix2.x_axis; let band_normal = transform.matrix2.y_axis.perp(); let line = if band_normal.length_squared() > 0. { axis.project_onto(band_normal) } else { axis }; @@ -414,7 +460,7 @@ fn create_peniko_gradient_brush(gradient_list: &List, multiplied_trans let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { + GradientType::Linear | GradientType::Mesh => peniko::LinearGradientPosition { start: to_point(start), end: to_point(end), } @@ -553,6 +599,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), + Graphic::MeshGradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), } } @@ -566,6 +613,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::MeshGradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), } } @@ -621,6 +669,14 @@ impl Render for Graphic { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } + Graphic::MeshGradient(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } Graphic::Text(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -640,6 +696,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::MeshGradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), } } @@ -653,6 +710,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), + Graphic::MeshGradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), } } @@ -666,6 +724,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), + Graphic::MeshGradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), } } @@ -679,6 +738,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.contains_artboard(), Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), + Graphic::MeshGradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), } } @@ -692,6 +752,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), + Graphic::MeshGradient(_) => (), Graphic::Text(_) => (), } } @@ -711,6 +772,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); @@ -735,7 +797,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); } @@ -757,7 +819,9 @@ impl Render for List { |render| { let mut render_params = render_params.clone(); render_params.artboard_background = Some(background); - content.render_svg(render, &render_params); + render.with_transform(artboard_transform, |render| { + content.render_svg(render, &render_params); + }); }, ); } @@ -885,7 +949,9 @@ impl Render for List { } }, |render| { - element.render_svg(render, render_params); + render.with_transform(transform, |render| { + element.render_svg(render, render_params); + }); }, ); } @@ -1097,7 +1163,7 @@ impl Render for List { MaskType::Mask }; - let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL); + let fill_graphic_list: Option>> = graphic_list_at(self, index, ATTR_FILL); let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE); @@ -1376,7 +1442,7 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); @@ -1459,7 +1525,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => { 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); @@ -2122,7 +2188,7 @@ impl Render for List { // The unit gradient line is the +X unit vector in local space, before the item's transform is applied match gradient_type { - GradientType::Linear => { + GradientType::Linear | GradientType::Mesh => { let _ = write!( &mut attributes.0.svg_defs, r#"{stop_string}"# @@ -2189,7 +2255,7 @@ impl Render for List { // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. let kind = match gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { + GradientType::Linear | GradientType::Mesh => peniko::LinearGradientPosition { start: to_point(DVec2::ZERO), end: to_point(DVec2::X), } @@ -2237,6 +2303,287 @@ impl Render for List { } } +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, _render_params: &RenderParams) { + for index in 0..self.len() { + let Some(mesh_gradient) = self.element(index) else { continue }; + let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + // FIXME: use below attrs + let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + + let Some(subpatches) = mesh_gradient.evaluator().and_then(|evaluator| { + evaluator.subdivide_patches_adaptive( + MESH_MAXIMUM_SUBDIVISIONS_PER_PATCH_PER_AXIS, + mesh_transform, + render.transform, + MESH_POSITION_ERROR_TOLERANCE_IN_VIEWPORT, + MESH_COLOR_ERROR_TOLERANCE, + ) + }) else { + continue; + }; + + let inflation_buckets: Vec<_> = subpatches.iter().map(mesh_subpatch_inflation_bucket).collect(); + let mut used_inflation_buckets = [false; MESH_MAXIMUM_INFLATION_BUCKET + 1]; + inflation_buckets.iter().for_each(|&bucket| used_inflation_buckets[bucket] = true); + + let shared_id = generate_uuid(); + write!( + &mut render.svg_defs, + r##""##, + ) + .unwrap(); + used_inflation_buckets + .iter() + .enumerate() + .filter(|(_, used)| **used) + .for_each(|(bucket, _)| { + let (clip_min, clip_size, paint_min, paint_size) = mesh_inflation_values(bucket); + write!( + &mut render.svg_defs, + r##" + + "##, + ) + .unwrap(); + }); + + let mut unique_id = generate_uuid(); + subpatches.iter().zip(inflation_buckets).for_each(|(subpatch, bucket)| { + let [top_left, top_right, bottom_left, bottom_right] = subpatch.corners; + let subpatch_transform = format_transform_matrix(DAffine2::from_cols(top_right.position - top_left.position, bottom_left.position - top_left.position, top_left.position)); + let (_, _, paint_min, paint_size) = mesh_inflation_values(bucket); + + // linear gradient for the bottom line + write!( + &mut render.svg_defs, + r##""##, + mesh_gamma_color_to_srgba8(bottom_left.gamma_color).to_rgba_hex(), + mesh_gamma_color_to_srgba8(bottom_right.gamma_color).to_rgba_hex(), + ) + .unwrap(); + + // linear gradient for the top line + write!( + &mut render.svg_defs, + r##""##, + mesh_gamma_color_to_srgba8(top_left.gamma_color).to_rgba_hex(), + mesh_gamma_color_to_srgba8(top_right.gamma_color).to_rgba_hex(), + ) + .unwrap(); + + render.parent_tag( + "g", + |attributes| { + attributes.push(ATTR_TRANSFORM, subpatch_transform); + attributes.push("clip-path", format!("url(#mc{shared_id}-{bucket})")); + }, + |render| { + // Force both gradient layers to composite before the outer clip applies edge coverage. + render.parent_tag( + "g", + |attributes| { + attributes.push("style", "isolation:isolate"); + attributes.push("mask", format!("url(#mi{shared_id}-{bucket})")); + }, + |render| { + render.leaf_tag("rect", |attributes| { + attributes.push("x", paint_min.to_string()); + attributes.push("y", paint_min.to_string()); + attributes.push("width", paint_size.to_string()); + attributes.push("height", paint_size.to_string()); + attributes.push("fill", format!("url(#gb{unique_id})")); + }); + + render.leaf_tag("rect", |attributes| { + attributes.push("x", paint_min.to_string()); + attributes.push("y", paint_min.to_string()); + attributes.push("width", paint_size.to_string()); + attributes.push("height", paint_size.to_string()); + attributes.push("fill", format!("url(#gt{unique_id})")); + attributes.push("mask", format!("url(#mm{shared_id}-{bucket})")); + }); + + // FIXME: For debug + render.leaf_tag("rect", |attributes| { + attributes.push("x", "0"); + attributes.push("y", "0"); + attributes.push("width", "1"); + attributes.push("height", "1"); + attributes.push("fill", "none"); + attributes.push("stroke", "black"); + attributes.push("stroke-width", "1"); + attributes.push("vector-effect", "non-scaling-stroke"); + }); + }, + ); + }, + ); + + unique_id += 1; + }); + } + } + + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + use vello::peniko; + + if let RenderMode::Outline = render_params.render_mode { + return; + } + + let linear_gradient = |start: DVec2, end: DVec2, start_color: SRGBA8, end_color: SRGBA8| { + let mut stops = peniko::ColorStops::new(); + for (offset, color) in [(0., start_color), (1., end_color)] { + 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: to_point(start), + end: to_point(end), + } + .into(), + stops, + extend: peniko::Extend::Pad, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }) + }; + let transparent_white = SRGBA8 { + red: 255, + green: 255, + blue: 255, + alpha: 0, + }; + let opaque_white = SRGBA8 { + red: 255, + green: 255, + blue: 255, + alpha: 255, + }; + let mask_gradient = linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), opaque_white, transparent_white); + let infinite_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); + + for index in 0..self.len() { + let Some(mesh_gradient) = self.element(index) else { continue }; + let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + + let viewport_zoom = if render_params.viewport_zoom.is_finite() && render_params.viewport_zoom > 0. { + render_params.viewport_zoom + } else { + 1. + }; + + let error_to_viewport = DAffine2::from_scale(DVec2::splat(viewport_zoom)) * parent_transform; + + let Some(subpatches) = mesh_gradient.evaluator().and_then(|evaluator| { + evaluator.subdivide_patches_adaptive( + MESH_MAXIMUM_SUBDIVISIONS_PER_PATCH_PER_AXIS, + mesh_transform, + error_to_viewport, + MESH_POSITION_ERROR_TOLERANCE_IN_VIEWPORT, + MESH_COLOR_ERROR_TOLERANCE, + ) + }) else { + continue; + }; + + 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; + } + + for subpatch in subpatches { + let [top_left, top_right, bottom_left, bottom_right] = subpatch.corners; + let local_to_mesh = DAffine2::from_cols(top_right.position - top_left.position, bottom_left.position - top_left.position, top_left.position); + let is_subpatch_flipped = local_to_mesh.matrix2.determinant() < 0.; + if is_subpatch_flipped { + continue; + } + + let local_to_device = parent_transform * local_to_mesh; + let local_to_scene = kurbo::Affine::new(local_to_device.to_cols_array()); + // Deshear the brush axes because Vello evaluates linear gradients from their transformed endpoints. + let inverse_local_to_device = if transform_is_invertible(local_to_device) { + local_to_device.inverse() + } else { + Default::default() + }; + let horizontal_gradient_to_device = gradient_placement(local_to_device, GradientType::Linear); + let vertical_axis = local_to_device.matrix2.y_axis; + let vertical_band_normal = local_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: local_to_device.translation, + }; + let horizontal_brush_transform = kurbo::Affine::new((inverse_local_to_device * horizontal_gradient_to_device).to_cols_array()); + let vertical_brush_transform = kurbo::Affine::new((inverse_local_to_device * vertical_gradient_to_device).to_cols_array()); + let bucket = mesh_subpatch_inflation_bucket(&subpatch); + let (clip_min, clip_size, paint_min, paint_size) = mesh_inflation_values(bucket); + let clip_rect = kurbo::Rect::new(clip_min, clip_min, clip_min + clip_size, clip_min + clip_size); + let paint_rect = kurbo::Rect::new(paint_min, paint_min, paint_min + paint_size, paint_min + paint_size); + + let bottom_gradient = linear_gradient( + DVec2::new(0., 1.), + DVec2::new(1., 1.), + mesh_gamma_color_to_srgba8(bottom_left.gamma_color), + mesh_gamma_color_to_srgba8(bottom_right.gamma_color), + ); + let top_gradient = linear_gradient( + DVec2::ZERO, + DVec2::X, + mesh_gamma_color_to_srgba8(top_left.gamma_color), + mesh_gamma_color_to_srgba8(top_right.gamma_color), + ); + + // Composite both horizontal gradients first, then apply edge coverage once through the outer clip. + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &clip_rect); + scene.fill(peniko::Fill::NonZero, local_to_scene, &bottom_gradient, Some(horizontal_brush_transform), &paint_rect); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &paint_rect); + scene.fill(peniko::Fill::NonZero, local_to_scene, &mask_gradient, Some(vertical_brush_transform), &paint_rect); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + local_to_scene, + &paint_rect, + ); + scene.fill(peniko::Fill::NonZero, local_to_scene, &top_gradient, Some(horizontal_brush_transform), &paint_rect); + scene.pop_layer(); + scene.pop_layer(); + scene.pop_layer(); + + // FIXME: debug render + let mut outline_path = clip_rect.to_path(0.1); + outline_path.apply_affine(local_to_scene); + let (outline_stroke, outline_color) = get_outline_styles(render_params); + scene.stroke(&outline_stroke, kurbo::Affine::IDENTITY, outline_color, None, &outline_path); + } + + if item_layer { + scene.pop_layer(); + } + } + } +} + /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). struct GlyphOutlinePen<'a> { path: &'a mut BezPath, diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 0a9807d0aa..4d7e48d25e 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -4,6 +4,8 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshPatch, MeshSubpatch, MeshSubpatchVertex}; + #[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))] @@ -12,6 +14,7 @@ pub enum GradientType { #[default] Linear, Radial, + Mesh, } // TODO: Someday we could switch this to a Box[T] to avoid over-allocation diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index d66703a690..5fd4956ed7 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -3,12 +3,13 @@ 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, GradientSpreadMethod, GradientStop, GradientType}; +pub use gradient::{Gradient, GradientSpreadMethod, GradientStop, GradientType, MeshGradient}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs new file mode 100644 index 0000000000..a2ea7912f7 --- /dev/null +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -0,0 +1,945 @@ +use core_types::{Color, render_complexity::RenderComplexity}; +use dyn_any::DynAny; +use glam::{DAffine2, DVec2, Vec4}; +use kurbo::{ParamCurve, PathSeg}; + +use crate::{ + Vector, + subpath::{BezierHandles, pathseg_points}, + vector::{ + PointId, SegmentId, StrokeId, + 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 { + /// 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], +} + +#[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), + } + } +} + +/// 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 vector = Vector::default(); + let mut corner_points = Vec::with_capacity(corner_count); + + for &position in positions { + let point_id = vector.point_domain.next_id(); + vector.point_domain.push(point_id, position); + corner_points.push(point_id); + } + + let mut horizontal_edges = Vec::with_capacity(corner_rows * (corner_columns - 1)); + for row in 0..corner_rows { + for column in 0..(corner_columns - 1) { + let start_index = row * corner_columns + column; + let end_index = start_index + 1; + + let segment_id = vector.segment_domain.next_id(); + vector.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + 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 = vector.segment_domain.next_id(); + vector.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + 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: vector, + 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 resolved patch by the provided row/column position, if any. + fn patch(&self, row: usize, column: usize) -> Option { + 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 { corners, colors, edges }) + } + + /// Iterator over all of the mesh gradient patches by row-major order, `None` if the patch is defined in unexpected structure. + pub fn patches(&self) -> impl Iterator> + '_ { + let patch_rows = self.corner_points.rows.saturating_sub(1); + let patch_columns = self.corner_points.columns.saturating_sub(1); + (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) + } + + /// Returns a new `MeshGradientEvaluator`. + pub fn evaluator(&self) -> Option { + MeshGradientEvaluator::new(self) + } + + /// 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. 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(()) + } + + 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. + pub fn insert_grid_line(&mut self, segment_id: SegmentId, t: f64) -> Option<()> { + #[derive(Clone, Copy)] + struct SplitSource { + segment_id: SegmentId, + start_point_id: PointId, + end_point_id: PointId, + segment: PathSeg, + } + + let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; + let grid_line_insertion_index = split_patch_index + 1; + let evaluator = self.evaluator()?; + 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 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 split_sources: 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(SplitSource { + segment_id, + start_point_id, + end_point_id, + segment, + }) + }) + .collect::>()?; + + // Calculate the new corners' information + let inserted_positions: Vec = split_sources.iter().map(|source| point_to_dvec2(source.segment.eval(t))).collect(); + let inserted_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(t as f32, across_t); + let [r, g, b, a] = evaluator.eval_color(patch_index, u, v); + Color::from_gamma_srgb_channels(r, g, b, a) + }) + .collect(); + + let mut inserted_corners = Vec::with_capacity(across_corner_count); + for &position in &inserted_positions { + let point_id = self.mesh_geometry.point_domain.next_id(); + self.mesh_geometry.point_domain.push(point_id, position); + inserted_corners.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 split_sources.iter().zip(&inserted_corners) { + let first_half = pathseg_points(source.segment.subsegment(0. ..t)); + let second_half = pathseg_points(source.segment.subsegment(t..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 inserted_corners.windows(2).zip(inserted_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, 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, &[&inserted_corners])?; + self.corner_colors.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&inserted_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<_> = split_sources.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)?; + + let merged_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push( + merged_segment_id, + start_point_id, + end_point_id, + (Some(point_to_dvec2(first_segment.p1)), Some(point_to_dvec2(second_segment.p2))), + 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(()) + } +} + +/// Single vertex of a subpatch. Only for rendering purpose. +#[derive(Clone, Copy)] +pub struct MeshSubpatchVertex { + pub position: DVec2, + pub gamma_color: [f32; 4], +} + +pub struct MeshSubpatch { + pub corners: [MeshSubpatchVertex; 4], +} + +#[derive(Clone, Copy)] +struct MeshCornerDerivatives { + u: Vec4, + v: Vec4, +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +struct MeshPatchEvaluator { + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], + // sRGB gamma space color in 0.-1. [top-left, top-right, bottom-left, bottom-right] + gamma_colors: [Vec4; 4], + /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_slopes: [MeshCornerDerivatives; 4], + /// Linear length of between each corner. [top, bottom, left, right] + lengths: [f32; 4], +} + +impl MeshPatchEvaluator { + /// Evaluate interpolated color in a mesh gradient's patch using bicubic hermite interpolation. + fn eval_color(&self, u: f32, v: f32) -> [f32; 4] { + let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { + let t_power_2 = t * t; + let t_power_3 = t_power_2 * t; + + let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; + let h2 = -2. * t_power_3 + 3. * t_power_2; + let h3 = t_power_3 - 2. * t_power_2 + t; + let h4 = t_power_3 - t_power_2; + + ma * h3 + a * h1 + b * h2 + mb * h4 + }; + + let [top_left_gamma, top_right_gamma, bottom_left_gamma, bottom_right_gamma] = self.gamma_colors; + let [top_length, bottom_length, left_length, right_length] = self.lengths; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; + + let interpolated_gamma_color: [f32; 4] = std::array::from_fn(|channel| { + let top_color_interpolated = hermite( + top_left_gamma[channel], + top_left_color_slope.u[channel] * top_length, + top_right_gamma[channel], + top_right_color_slope.u[channel] * top_length, + u, + ); + let bottom_color_interpolated = hermite( + bottom_left_gamma[channel], + bottom_left_color_slope.u[channel] * bottom_length, + bottom_right_gamma[channel], + bottom_right_color_slope.u[channel] * bottom_length, + u, + ); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); + hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) + }); + + interpolated_gamma_color + } + + fn eval_vertex(&self, u: f64, v: f64, mesh_transform: DAffine2) -> MeshSubpatchVertex { + let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; + let [top_left, top_right, bottom_left, bottom_right] = self.corners; + + let top_u = point_to_dvec2(top_seg.eval(u)); + let bottom_u = point_to_dvec2(bottom_seg.eval(u)); + let left_v = point_to_dvec2(left_seg.eval(v)); + let right_v = point_to_dvec2(right_seg.eval(v)); + + let s_c = (1. - v) * top_u + v * bottom_u; + let s_d = (1. - u) * left_v + u * right_v; + let s_b = top_left * (1. - u) * (1. - v) + top_right * u * (1. - v) + bottom_left * (1. - u) * v + bottom_right * u * v; + + MeshSubpatchVertex { + position: mesh_transform.transform_point2(s_c + s_d - s_b), + gamma_color: self.eval_color(u as f32, v as f32), + } + } +} + +/// Struct for evaluating color for subpatch corners. +/// The main purpose is to prevent duplicated calculation of the slopes for hermite interpolation for each subpatch. +#[derive(Clone)] +pub struct MeshGradientEvaluator { + /// List of required data for color interpolation, row major order. + patches: Vec, +} + +impl MeshGradientEvaluator { + // TODO: probably it is better to use u/v for slope calculation + pub fn new(mesh_gradient: &MeshGradient) -> Option { + let [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::>()?; + + // We need to calculate the color derivatives in sRGB since SVG uses sRGB for color interpolation. + // `color-interpolation="linearRGB"` is part of the SVG2 spec but not yet implemented in major browsers as of Jul. 2026. + // See also: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/color-interpolation + let gamma_colors: Vec = mesh_gradient.corner_colors.values.iter().map(|color| Vec4::from_array(color.to_gamma_srgb_channels())).collect(); + + // Calculate the slope of the `curr_index` corner by FDM. The slope is derived from the linear distance from the previous/next corners. + let calculate_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let prev_color = gamma_colors[prev_index]; + let curr_color = gamma_colors[curr_index]; + let next_color = gamma_colors[next_index]; + + let [prev_pos, curr_pos, next_pos] = [prev_index, curr_index, next_index].map(|index| corner_positions[index]); + let prev_distance = curr_pos.distance(prev_pos) as f32; + let next_distance = next_pos.distance(curr_pos) as f32; + + if prev_index == curr_index { + // FIXME: resolve zero-division problem + (next_color - curr_color) / next_distance + } else if next_index == curr_index { + (curr_color - prev_color) / prev_distance + } else { + let backward_diff = (curr_color - prev_color) / prev_distance; + let forward_diff = (next_color - curr_color) / next_distance; + let central_diff = (backward_diff + forward_diff) / 2.; + + // Prevent overshooting by applying a zero slope at local minimum/maximum + // TODO: consider clamping slope by a constant value + Vec4::from_array(std::array::from_fn( + |channel| { + if backward_diff[channel] * forward_diff[channel] <= 0. { 0. } else { central_diff[channel] } + }, + )) + } + }; + + let sample_index = |row: isize, column: isize| -> usize { + let clamped_column = column.clamp(0, corner_columns as isize - 1) as usize; + let clamped_row = row.clamp(0, corner_rows as isize - 1) as usize; + clamped_row * corner_columns + clamped_column + }; + + let mut corner_slopes = Vec::with_capacity(corner_rows * corner_columns); + for row in 0..corner_rows as isize { + for col in 0..corner_columns as isize { + let curr_index = sample_index(row, col); + let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + corner_slopes.push(MeshCornerDerivatives { u, v }); + } + } + + let mut patch_color_data = Vec::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_gamma_colors = corner_indices.map(|index| gamma_colors[index]); + let color_slopes = corner_indices.map(|index| corner_slopes[index]); + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; + let lengths = [ + top_left_pos.distance(top_right_pos) as f32, + bottom_left_pos.distance(bottom_right_pos) as f32, + top_left_pos.distance(bottom_left_pos) as f32, + top_right_pos.distance(bottom_right_pos) as f32, + ]; + patch_color_data.push(MeshPatchEvaluator { + corners: patch.corners, + edges: patch.edges, + gamma_colors: patch_gamma_colors, + color_slopes, + lengths, + }); + } + } + + Some(Self { patches: patch_color_data }) + } + + fn eval_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { + self.patches[patch_index].eval_color(u, v) + } + + /// Recursively subdivide only the regions that do not approximate the source mesh within the given tolerances. + pub fn subdivide_patches_adaptive( + &self, + maximum_subdivisions_per_patch_per_axis: usize, + mesh_transform: DAffine2, + parent_to_viewport: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, + ) -> Option> { + if !maximum_subdivisions_per_patch_per_axis.is_power_of_two() + || !position_error_tolerance.is_finite() + || position_error_tolerance < 0. + || !color_error_tolerance.is_finite() + || color_error_tolerance < 0. + { + return None; + } + + let samples = [0., 0.25, 0.5, 0.75, 1.]; + let mut subpatches = Vec::new(); + for patch in &self.patches { + let mut pending = vec![(0., 0., 1., 1_usize)]; + while let Some((u_start, v_start, stride, subdivisions_per_axis)) = pending.pop() { + let top_left = patch.eval_vertex(u_start, v_start, mesh_transform); + let top_right = patch.eval_vertex(u_start + stride, v_start, mesh_transform); + let bottom_left = patch.eval_vertex(u_start, v_start + stride, mesh_transform); + let bottom_right = patch.eval_vertex(u_start + stride, v_start + stride, mesh_transform); + let corners = [top_left, top_right, bottom_left, bottom_right]; + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = corners.map(|vertex| Vec4::from_array(vertex.gamma_color)); + + let mut within_tolerance = true; + 'error_samples: for &local_v in &samples { + for &local_u in &samples { + let expected_vertex = patch.eval_vertex(u_start + local_u * stride, v_start + local_v * stride, mesh_transform); + // Approximiated position/color derived by bilerp, which simulates the values in the rendered parallelogram with two linear gradients + let approximated_position = top_left.position + (top_right.position - top_left.position) * local_u + (bottom_left.position - top_left.position) * local_v; + let top_color = top_left_color.lerp(top_right_color, local_u as f32); + let bottom_color = bottom_left_color.lerp(bottom_right_color, local_u as f32); + let approximated_color = top_color.lerp(bottom_color, local_v as f32); + + let position_error_vector = expected_vertex.position - approximated_position; + let position_error = parent_to_viewport.transform_vector2(position_error_vector).length(); + let color_error = (Vec4::from_array(expected_vertex.gamma_color) - approximated_color).abs().max_element(); + if !position_error.is_finite() || !color_error.is_finite() || position_error > position_error_tolerance || color_error > color_error_tolerance { + within_tolerance = false; + break 'error_samples; + } + } + } + + if within_tolerance || subdivisions_per_axis >= maximum_subdivisions_per_patch_per_axis { + subpatches.push(MeshSubpatch { corners }); + } else { + let half_stride = stride / 2.; + let child_subdivisions_per_axis = subdivisions_per_axis * 2; + pending.extend([ + (u_start + half_stride, v_start + half_stride, half_stride, child_subdivisions_per_axis), + (u_start, v_start + half_stride, half_stride, child_subdivisions_per_axis), + (u_start + half_stride, v_start, half_stride, child_subdivisions_per_axis), + (u_start, v_start, half_stride, child_subdivisions_per_axis), + ]); + } + } + } + + Some(subpatches) + } +} + +impl RenderComplexity for MeshGradient { + fn render_complexity(&self) -> usize { + // FIXME: implement proper complexity calc + 10000000 + } +} + +impl core_types::bounds::BoundingBox for MeshGradient { + fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + // FIXME: infinite? finite? + core_types::bounds::RenderBoundingBox::Infinite + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + // FIXME: implement actual check of the bounding box + let start = transform.transform_point2(DVec2::ZERO); + let end = transform.transform_point2(DVec2::X); + core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + } +} + +/// Helper to create initial handles. +fn handles(start: DVec2, end: DVec2) -> (Option, Option) { + (Some(start + (end - start) / 3.), Some(end + (start - end) / 3.)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_position(actual: DVec2, expected: DVec2) { + assert!((actual - expected).length() < 1e-10, "expected {expected:?}, got {actual:?}"); + } + + #[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, 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, 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, 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, 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); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 63f9b87650..04686239b0 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -941,6 +941,15 @@ impl Vector { self.segment_points_from_id(id).map(|(_, _, bezier)| bezier) } + /// Tries to convert a segment with the specified id to a [`PathSeg`], returning None if the id is invalid. + pub fn path_segment_from_id(&self, id: SegmentId) -> Option { + let segment_index = self.segment_domain.id_to_index(id)?; + let start_index = *self.segment_domain.start_point().get(segment_index)?; + let end_index = *self.segment_domain.end_point().get(segment_index)?; + let handles = *self.segment_domain.handles().get(segment_index)?; + Some(self.path_segment_from_index(start_index, end_index, handles)) + } + /// Tries to convert a segment with the specified id to the start and end points and a [`Bezier`], returning None if the id is invalid. pub fn segment_points_from_id(&self, id: SegmentId) -> Option<(PointId, PointId, Bezier)> { Some(self.segment_points_from_index(self.segment_domain.id_to_index(id)?)) diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 54aa4084e1..525967b286 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -58,7 +58,7 @@ pub mod subpath { } pub mod gradient { - pub use vector_types::{Gradient, GradientStop}; + pub use vector_types::{Gradient, GradientStop, MeshGradient}; } pub mod transform { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 1474aa2be3..a62d7f9ba4 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -14,7 +14,7 @@ use math_parser::value::{Number, Value}; use num_traits::Pow; use rand::{Rng, SeedableRng}; use std::ops::{Add, Div, Mul, Rem, Sub}; -use vector_types::Gradient; +use vector_types::{Gradient, MeshGradient}; /// The struct that stores the context for the maths parser. /// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. @@ -1016,6 +1016,12 @@ fn spread_method(_: impl Ctx, gradient: Item, spread_method: Item) -> Item { + mesh_gradient +} + /// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). #[node_macro::node(category("Color"))] fn sample_gradient(_: impl Ctx, _primary: (), gradient: Item, position: Item) -> Item { diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index bcfed58a5a..de0dfac609 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -297,6 +297,8 @@ fn flatten_vector(graphic_list: &List) -> List { Item::from_parts(element, attributes) }) .collect::>(), + // FIXME: + Graphic::MeshGradient(mesh_gradient) => Vec::new(), Graphic::Text(text) => { // Shape the glyphs into vectors (each item's own transform is applied), then compose the parent's transform like the other arms let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index c2efc76a16..c1f4b39780 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -33,7 +33,7 @@ use vector_types::vector::misc::{ }; use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; -use vector_types::{GradientSpreadMethod, GradientType}; +use vector_types::{GradientSpreadMethod, GradientType, MeshGradient}; /// Implemented for `List` types that contain vector items reachable via mutable access. /// Used by the whole-collection Assign Colors node so it can apply to either `List` or `List`. @@ -178,13 +178,13 @@ where async fn fill( _: impl Ctx, /// The content with vector paths to apply the fill style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The fill to paint the path with. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] fill: F, _backup_color: Item, @@ -203,51 +203,78 @@ where let mut content = content; let mut fill = fill.into_graphic_list(); - // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire - for graphic in fill.iter_element_values_mut() { - let Graphic::Gradient(gradient) = graphic else { continue }; - - if gradient.iter_attribute_values::(ATTR_GRADIENT_TYPE).is_none() { - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_TYPE) { - *value = _gradient_type; - } - } - - if gradient.iter_attribute_values::(ATTR_SPREAD_METHOD).is_none() { - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_SPREAD_METHOD) { - *value = _spread_method; - } - } - - if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { - // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) - let transform = if _has_transform { - _transform - } else { - let mut bounds: Option<[DVec2; 2]> = None; - content.for_each_vector_mut(|vector, _| { - if let Some([min, max]) = vector.bounding_box() { - bounds = Some(match bounds { - Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], - None => [min, max], - }); - } + let mut vector_bounds = || { + let mut bounds: Option<[DVec2; 2]> = None; + content.for_each_vector_mut(|vector, _| { + if let Some([min, max]) = vector.bounding_box() { + bounds = Some(match bounds { + Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], + None => [min, max], }); - - // Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box` - let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); + } + }); + bounds + .map(|bounds| { + let [min, mut max] = bounds; if max.x - min.x < 1e-10 { max.x = min.x + 1.; } if max.y - min.y < 1e-10 { max.y = min.y + 1.; } - initial_gradient_transform_for_bounding_box([min, max]) - }; + [min, max] + }) + .unwrap_or([DVec2::ZERO, DVec2::ONE]) + }; + + // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire + for graphic in fill.iter_element_values_mut() { + match graphic { + Graphic::Gradient(gradient) => { + if gradient.iter_attribute_values::(ATTR_GRADIENT_TYPE).is_none() { + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_TYPE) { + *value = _gradient_type; + } + } + + if gradient.iter_attribute_values::(ATTR_SPREAD_METHOD).is_none() { + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_SPREAD_METHOD) { + *value = _spread_method; + } + } + + if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { + // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) + let transform = if _has_transform { + _transform + } else { + // Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box` + let [min, max] = vector_bounds(); + initial_gradient_transform_for_bounding_box([min, max]) + }; - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - *value = transform; + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } + } + Graphic::MeshGradient(mesh_gradient) => { + if mesh_gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { + // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) + let transform = if _has_transform { + _transform + } else { + let [min, max] = vector_bounds(); + let size = max - min; + DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) + }; + + for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } } + _ => continue, } } @@ -260,13 +287,13 @@ where async fn stroke( _: impl Ctx, /// The content with vector paths to apply the stroke style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The stroke paint. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] paint: P, /// The stroke thickness. @@ -324,7 +351,7 @@ where vector.stroke = Some(stroke); }); - let paint = paint.into_graphic_list(); + let paint: List = paint.into_graphic_list(); content.set_vector_paint(ATTR_STROKE, paint); content }