diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index a6e3fb38e9..dbb1a0fe41 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -17,6 +17,12 @@ use graphene_core::{Artboard, Color}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum GraphOperationMessage { + FillRaster { + layer: LayerNodeIdentifier, + fills: Vec, + start_pos: Vec, + tolerance: f64, + }, FillSet { layer: LayerNodeIdentifier, fill: Fill, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 645b66a1d9..7addf8a573 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -36,6 +36,11 @@ impl MessageHandler> for Gr let network_interface = data.network_interface; match message { + GraphOperationMessage::FillRaster { layer, fills, start_pos, tolerance } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.fill_raster(fills, start_pos, tolerance); + } + } GraphOperationMessage::FillSet { layer, fill } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.fill_set(fill); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 27d879a43d..1e5f07d5a3 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -347,6 +347,13 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::BlendMode(blend_mode), false), false); } + pub fn fill_raster(&mut self, fills: Vec, start_pos: Vec, tolerance: f64) { + let Some(raster_fill_node_id) = self.existing_node_id("Flood Fill", true) else { return }; + self.set_input_with_refresh(InputConnector::node(raster_fill_node_id, 1), NodeInput::value(TaggedValue::VecFill(fills), false), false); + self.set_input_with_refresh(InputConnector::node(raster_fill_node_id, 2), NodeInput::value(TaggedValue::VecDVec2(start_pos), false), false); + self.set_input_with_refresh(InputConnector::node(raster_fill_node_id, 3), NodeInput::value(TaggedValue::F64(tolerance), false), false); + } + pub fn stroke_set(&mut self, stroke: Stroke) { let Some(stroke_node_id) = self.existing_node_id("Stroke", true) else { return }; diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 6bca37c0df..32490ec14f 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,6 +1,10 @@ use super::tool_prelude::*; +use crate::messages::portfolio::document::graph_operation::transform_utils::get_current_transform; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; -use graphene_core::vector::style::Fill; +use graph_craft::document::value::TaggedValue; +use graphene_std::vector::style::Fill; + #[derive(Default)] pub struct FillTool { fsm_state: FillToolFsmState, @@ -38,7 +42,8 @@ impl LayoutHolder for FillTool { impl<'a> MessageHandler> for FillTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, tool_data: &mut ToolActionHandlerData<'a>) { - self.fsm_state.process_event(message, &mut (), tool_data, &(), responses, true); + let raster_fill_tool_data = &mut FillToolData::default(); + self.fsm_state.process_event(message, raster_fill_tool_data, tool_data, &(), responses, true); } fn actions(&self) -> ActionList { match self.fsm_state { @@ -71,11 +76,50 @@ enum FillToolFsmState { Filling, } +#[derive(Clone, Debug, Default)] +struct FillToolData { + fills: Vec, + start_pos: Vec, + tolerance: f64, +} + +impl FillToolData { + fn load_existing_fills(&mut self, document: &mut DocumentMessageHandler, layer_identifier: LayerNodeIdentifier) -> Option { + let node_graph_layer = NodeGraphLayer::new(layer_identifier, &document.network_interface); + let existing_fills = node_graph_layer.find_node_inputs("Flood Fill"); + + if let Some(existing_fills) = existing_fills { + let fills = if let Some(TaggedValue::VecFill(fills)) = existing_fills[1].as_value().cloned() { + fills + } else { + Vec::new() + }; + let start_pos = if let Some(TaggedValue::VecDVec2(start_pos)) = existing_fills[2].as_value().cloned() { + start_pos + } else { + Vec::new() + }; + let tolerance = if let Some(TaggedValue::F64(tolerance)) = existing_fills[3].as_value().cloned() { + tolerance + } else { + 1. + }; + + *self = Self { fills, start_pos, tolerance }; + } + + // TODO: Why do we overwrite the tolerance that we just set a couple lines above? + self.tolerance = 1.; + + None + } +} + impl Fsm for FillToolFsmState { - type ToolData = (); + type ToolData = FillToolData; type ToolOptions = (); - fn transition(self, event: ToolMessage, _tool_data: &mut Self::ToolData, handler_data: &mut ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque) -> Self { + fn transition(self, event: ToolMessage, tool_data: &mut Self::ToolData, handler_data: &mut ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque) -> Self { let ToolActionHandlerData { document, global_tool_data, input, .. } = handler_data; @@ -83,13 +127,7 @@ impl Fsm for FillToolFsmState { let ToolMessage::Fill(event) = event else { return self }; match (self, event) { (FillToolFsmState::Ready, color_event) => { - let Some(layer_identifier) = document.click(input) else { - return self; - }; - // If the layer is a raster layer, don't fill it, wait till the flood fill tool is implemented - if NodeGraphLayer::is_raster_layer(layer_identifier, &mut document.network_interface) { - return self; - } + let Some(layer_identifier) = document.click(input) else { return self }; let fill = match color_event { FillToolMessage::FillPrimaryColor => Fill::Solid(global_tool_data.primary_color.to_gamma_srgb()), FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color.to_gamma_srgb()), @@ -97,11 +135,52 @@ impl Fsm for FillToolFsmState { }; responses.add(DocumentMessage::AddTransaction); - responses.add(GraphOperationMessage::FillSet { layer: layer_identifier, fill }); + + // If the layer is a raster layer, we perform a flood fill + if NodeGraphLayer::is_raster_layer(layer_identifier, &mut document.network_interface) { + // Try to load existing fills for this layer + tool_data.load_existing_fills(document, layer_identifier); + + // Get position in layer space + let layer_pos = document + .network_interface + .document_metadata() + .downstream_transform_to_viewport(layer_identifier) + .inverse() + .transform_point2(input.mouse.position); + + let node_graph_layer = NodeGraphLayer::new(layer_identifier, &document.network_interface); + if let Some(transform_inputs) = node_graph_layer.find_node_inputs("Transform") { + let image_transform = get_current_transform(transform_inputs); + let image_local_pos = image_transform.inverse().transform_point2(layer_pos); + + // Store the fill in our tool data with its position + tool_data.fills.push(fill.clone()); + tool_data.start_pos.push(image_local_pos); + } + + // Send the fill operation message + responses.add(GraphOperationMessage::FillRaster { + layer: layer_identifier, + fills: tool_data.fills.clone(), + start_pos: tool_data.start_pos.clone(), + tolerance: tool_data.tolerance, + }); + } + // Otherwise the layer is assumed to be a vector layer, so we apply a vector fill + else { + responses.add(GraphOperationMessage::FillSet { layer: layer_identifier, fill }); + } FillToolFsmState::Filling } - (FillToolFsmState::Filling, FillToolMessage::PointerUp) => FillToolFsmState::Ready, + (FillToolFsmState::Filling, FillToolMessage::PointerUp) => { + // Clear the `fills` and `start_pos` data when we're done + tool_data.fills.clear(); + tool_data.start_pos.clear(); + + FillToolFsmState::Ready + } (FillToolFsmState::Filling, FillToolMessage::Abort) => { responses.add(DocumentMessage::AbortTransaction); @@ -136,7 +215,6 @@ mod test_fill { async fn get_fills(editor: &mut EditorTestUtils) -> Vec { let instrumented = editor.eval_graph().await; - instrumented.grab_all_input::>(&editor.runtime).collect() } @@ -149,15 +227,6 @@ mod test_fill { assert!(get_fills(&mut editor,).await.is_empty()); } - #[tokio::test] - async fn ignore_raster() { - let mut editor = EditorTestUtils::create(); - editor.new_document().await; - editor.create_raster_image(Image::new(100, 100, Color::WHITE), Some((0., 0.))).await; - editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; - assert!(get_fills(&mut editor,).await.is_empty()); - } - #[tokio::test] async fn primary() { let mut editor = EditorTestUtils::create(); diff --git a/node-graph/gcore/Cargo.toml b/node-graph/gcore/Cargo.toml index e9c9180f80..1dd4e5fdbe 100644 --- a/node-graph/gcore/Cargo.toml +++ b/node-graph/gcore/Cargo.toml @@ -78,6 +78,7 @@ wasm-bindgen = { workspace = true, optional = true } js-sys = { workspace = true, optional = true } web-sys = { workspace = true, optional = true, features = [ "HtmlCanvasElement", + "console", ] } image = { workspace = true, optional = true, default-features = false, features = [ "png", diff --git a/node-graph/gcore/src/ops.rs b/node-graph/gcore/src/ops.rs index 768950418a..936cd546a1 100644 --- a/node-graph/gcore/src/ops.rs +++ b/node-graph/gcore/src/ops.rs @@ -132,7 +132,7 @@ fn modulo>>, T: Copy #[default(2.)] #[implementations(f64, f64, &f64, &f64, f32, f32, &f32, &f32, u32, u32, &u32, &u32, DVec2, f64, DVec2)] modulus: T, - always_positive: bool, + #[default(true)] always_positive: bool, ) -> >::Output { if always_positive { (numerator % modulus + modulus) % modulus } else { numerator % modulus } } diff --git a/node-graph/gcore/src/raster/color.rs b/node-graph/gcore/src/raster/color.rs index 3be18d1952..5319c020ff 100644 --- a/node-graph/gcore/src/raster/color.rs +++ b/node-graph/gcore/src/raster/color.rs @@ -1049,6 +1049,65 @@ impl Color { ..*self } } + + /// Convert RGB to XYZ color space + fn to_xyz(self) -> [f64; 3] { + let r = self.red as f64; + let g = self.green as f64; + let b = self.blue as f64; + + // sRGB to XYZ conversion matrix + let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375; + let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750; + let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041; + + [x, y, z] + } + + /// Convert XYZ to LAB color space + fn xyz_to_lab(xyz: [f64; 3]) -> [f64; 3] { + // D65 illuminant reference values + let xn = 0.950489; + let yn = 1.; + let zn = 1.088840; + + let x = xyz[0]; + let y = xyz[1]; + let z = xyz[2]; + + let fx = if x / xn > 0.008856 { (x / xn).powf(1. / 3.) } else { (903.3 * x / xn + 16.) / 116. }; + let fy = if y / yn > 0.008856 { (y / yn).powf(1. / 3.) } else { (903.3 * y / yn + 16.) / 116. }; + let fz = if z / zn > 0.008856 { (z / zn).powf(1. / 3.) } else { (903.3 * z / zn + 16.) / 116. }; + + let l = 116. * fy - 16.; + let a = 500. * (fx - fy); + let b = 200. * (fy - fz); + + [l, a, b] + } + + /// Convert RGB to LAB color space + pub fn to_lab(&self) -> [f64; 3] { + Self::xyz_to_lab(self.to_xyz()) + } + + /// Calculate the distance between two colors in LAB space + pub fn lab_distance_squared(&self, other: &Color) -> f64 { + let lab1 = self.to_lab(); + let lab2 = other.to_lab(); + + // Euclidean distance in LAB space + (lab1[0] - lab2[0]).powi(2) + (lab1[1] - lab2[1]).powi(2) + (lab1[2] - lab2[2]).powi(2) + } + + /// Check if two colors are similar within a threshold in LAB space + pub fn is_similar_lab(&self, other: &Color, threshold: f64) -> bool { + let lab1 = self.to_lab(); + let lab2 = other.to_lab(); + let distance = self.lab_distance(other).min(100.); + let alpha_diff = (self.a() - other.a()).abs(); + distance <= threshold && alpha_diff < f32::EPSILON + } } #[test] diff --git a/node-graph/gcore/src/raster/discrete_srgb.rs b/node-graph/gcore/src/raster/discrete_srgb.rs index 13a06e30ab..3dcd1a85b2 100644 --- a/node-graph/gcore/src/raster/discrete_srgb.rs +++ b/node-graph/gcore/src/raster/discrete_srgb.rs @@ -162,7 +162,10 @@ mod tests { #[test] fn test_float_to_srgb_u8() { for u in 0..=u8::MAX { - assert!(srgb_u8_to_float(u) == srgb_u8_to_float_ref(u)); + let float_val = srgb_u8_to_float(u); + let ref_val = srgb_u8_to_float_ref(u); + // Allow for a small epsilon difference due to floating-point precision + assert!((float_val - ref_val).abs() < 1e-5); } } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index caec7a487a..79055485d1 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -172,6 +172,7 @@ tagged_value! { // ImaginateMaskStartingFill(ImaginateMaskStartingFill), // ImaginateController(ImaginateController), Fill(graphene_core::vector::style::Fill), + VecFill(Vec), Stroke(graphene_core::vector::style::Stroke), F64Array4([f64; 4]), // TODO: Eventually remove this alias document upgrade code @@ -426,4 +427,10 @@ mod fake_hash { self.1.hash(state) } } + impl FakeHash for (T, U) { + fn hash(&self, state: &mut H) { + self.0.hash(state); + self.1.hash(state); + } + } } diff --git a/node-graph/gstd/src/raster.rs b/node-graph/gstd/src/raster.rs index 8384c2e923..4cef03701b 100644 --- a/node-graph/gstd/src/raster.rs +++ b/node-graph/gstd/src/raster.rs @@ -7,6 +7,7 @@ use graphene_core::raster::{ Alpha, AlphaMut, Bitmap, BitmapMut, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, Linear, LinearChannel, Luminance, NoiseType, Pixel, RGBMut, RedGreenBlue, Sample, }; use graphene_core::transform::{Transform, TransformMut}; +use graphene_core::vector::style::{Fill, Gradient}; use graphene_core::{AlphaBlending, Color, Ctx, ExtractFootprint, GraphicElement, Node}; use rand::prelude::*; use rand_chacha::ChaCha8Rng; @@ -684,6 +685,104 @@ fn mandelbrot_impl(c: Vec2, max_iter: usize) -> usize { max_iter } +#[node_macro::node(category("Raster"))] +fn flood_fill + 'n + Send + Clone>( + _: impl Ctx, + #[implementations(ImageFrameTable)] + /// The raster content to apply the flood fill to. + mut image: ImageFrameTable, + #[implementations( + Vec, + Vec>, + Vec, + Vec + )] + /// The fills to paint the path with. + #[default(Vec)] + fills: Vec, + /// The starting positions of the flood fill points in the layer's local coordinates. + positions: Vec, + // TODO: What is the range? I'd expect 0-255, but we should also rescale that to 0-1. But this crashes at larger values approaching 100. And tolerance should be per-position not global to the node. + /// The threshold for color similarity in LAB space. + #[default(1.)] + #[range((0., 100.))] + similarity_threshold: f64, +) -> ImageFrameTable { + let width = image.width(); + let height = image.height(); + if width == 0 || height == 0 || fills.is_empty() || positions.is_empty() { + return image; + } + + // Process the minimum number of fill and position pairs + let fill_count = fills.len().min(positions.len()); + + for i in 0..fill_count { + // Get the fill and position for this iteration + let fill = fills[i].clone().into(); + let position = positions[i]; + + // Scale position to pixel coordinates + let image_size = DVec2::new(width as f64, height as f64); + let local_pos = position * image_size; + + // Convert to pixel coordinates + let pixel_x = local_pos.x.floor() as i32; + let pixel_y = local_pos.y.floor() as i32; + + let color = match fill { + Fill::Solid(color) => color.to_linear_srgb(), + Fill::Gradient(_) => Color::RED, // TODO: Implement raster gradient fill + Fill::None => Color::TRANSPARENT, + }; + + // Get the target color at the clicked position + let Some(target_color) = image.get_pixel(pixel_x as u32, pixel_y as u32) else { continue }; + + // If the target color is the same as the fill color, no need to fill + if target_color == color { + continue; + } + + // Create a copy of the original image data for comparison + let original_data = image.one_instance().instance.data.clone(); + + // Flood fill algorithm using a stack and visited set + let mut stack = Vec::new(); + let mut visited = std::collections::HashSet::new(); + stack.push((pixel_x, pixel_y)); + + while let Some((x, y)) = stack.pop() { + // Check bounds + if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 { + continue; + } + + // Skip if already visited + if !visited.insert((x, y)) { + continue; + } + + // Get current pixel + if let Some(pixel) = image.get_pixel_mut(x as u32, y as u32) { + // Get the original color at this position + let original_color = original_data[y as usize * width as usize + x as usize]; + + // If pixel matches target color, fill it and add neighbors to stack + if original_color.is_similar_lab(&target_color, similarity_threshold) { + *pixel = color; + stack.push((x + 1, y)); // Right + stack.push((x - 1, y)); // Left + stack.push((x, y + 1)); // Down + stack.push((x, y - 1)); // Up + } + } + } + } + + image +} + fn map_color(iter: usize, max_iter: usize) -> Color { let v = iter as f32 / max_iter as f32; Color::from_rgbaf32_unchecked(v, v, v, 1.)