From 8a93b47788e841075ff60bea393999edccdfcc77 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 4 Aug 2026 18:32:15 -0700 Subject: [PATCH] Let the Fill and Gradient tools paint blank layers with whole-expanse colors and gradients (#4407) * Let the Gradient and Fill tools paint blank layers with whole-expanse gradients and colors * Make it work on layers with un-fed Transform nodes * Show the Fill tool's hover pattern over the whole expanse of layers painted by a color chain * Let the Fill and Gradient tools replace one another's whole-expanse paint on a selected layer * Keep a shared paint node alive when a tool takes over a layer, and share the paint value lookup * Stop a chain node from being shifted twice when a node is inserted in front of it --- .../graph_operation_message.rs | 4 + .../graph_operation_message_handler.rs | 7 +- .../document/graph_operation/utility_types.rs | 80 +++- .../utility_types/network_interface/layout.rs | 7 + .../network_interface/queries.rs | 5 + .../utility_types/network_interface/view.rs | 8 + .../graph_modification_utils.rs | 90 ++++- .../messages/tool/tool_messages/fill_tool.rs | 373 +++++++++++++++++- .../tool/tool_messages/gradient_tool.rs | 196 ++++++++- 9 files changed, 739 insertions(+), 31 deletions(-) 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 ae578cb9cf..5f1c13e36c 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 @@ -20,6 +20,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, color: Option, }, + ColorValueSet { + layer: LayerNodeIdentifier, + color: Color, + }, FillGradientSet { layer: LayerNodeIdentifier, #[serde(skip)] 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 d99be512b3..17b9397f60 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 @@ -40,6 +40,11 @@ impl MessageHandler> for modify_inputs.fill_color_set(color); } } + GraphOperationMessage::ColorValueSet { layer, color } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.color_value_set(color); + } + } GraphOperationMessage::FillGradientSet { layer, gradient, @@ -324,7 +329,7 @@ impl MessageHandler> for GraphOperationMessage::NewColorFillLayer { node_id, color, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(node_id); - modify_inputs.insert_color_value(color, layer); + modify_inputs.insert_color_value(color, layer, InputConnector::layer_secondary_input(layer.to_node())); network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } 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 a5a07adebe..89aa49174a 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -5,7 +5,9 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface}; use crate::messages::prelude::*; -use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input}; +use crate::messages::tool::common_functionality::graph_modification_utils::{ + ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, +}; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; @@ -233,14 +235,41 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import); } - pub fn insert_color_value(&mut self, color: Color, layer: LayerNodeIdentifier) { + pub fn insert_color_value(&mut self, color: Color, layer: LayerNodeIdentifier, attachment_input: InputConnector) -> NodeId { let color_value = resolve_proto_node_type(graphene_std::math_nodes::color_value::IDENTIFIER) .expect("Color Value node does not exist") .node_template_input_override([Some(NodeInput::value(TaggedValue::None, false)), Some(NodeInput::value(TaggedValue::Color(color), false))]); let color_value_id = NodeId::new(); self.network_interface.insert_node(color_value_id, color_value, &[]); - self.network_interface.move_node_to_chain_start(&color_value_id, layer, &[], self.import); + self.start_paint_chain(&color_value_id, layer, attachment_input); + + color_value_id + } + + /// Clear the whole-expanse paint one tool left on a layer so the other can start its own chain there. + /// Severing at the attachment detaches the layer from whatever the walk stopped at, which is the only part a node + /// the rest of the graph also draws from is subjected to, since such a node is never among those deleted. + fn clear_paint_chain(&mut self, paint_chain: &ReplaceablePaintChain) { + self.network_interface.disconnect_input(&paint_chain.attachment_input, &[]); + + if !paint_chain.nodes.is_empty() { + self.network_interface.delete_nodes(paint_chain.nodes.clone(), false, &[]); + } + } + + /// Wire a node that paints the layer's whole expanse into the start of its chain, + /// or past the 'Transform' nodes a blank layer already carries so those go on applying to the paint. + fn start_paint_chain(&mut self, node_id: &NodeId, layer: LayerNodeIdentifier, attachment_input: InputConnector) { + let layer_content_input = InputConnector::layer_secondary_input(layer.to_node()); + + if attachment_input == layer_content_input { + self.network_interface.move_node_to_chain_start(node_id, layer, &[], self.import); + return; + } + + self.network_interface.set_input(&attachment_input, NodeInput::node(*node_id, 0), &[]); + self.network_interface.set_chain_position(node_id, &[]); } pub fn insert_image_data(&mut self, image: Image, layer: LayerNodeIdentifier) { @@ -503,6 +532,29 @@ impl<'a> ModifyInputsContext<'a> { ); } + /// Update the chain's 'Color Value' node, or start a chain with one on an empty layer, painting the layer's whole expanse. + pub fn color_value_set(&mut self, color: Color) { + let Some(output_layer) = self.get_output_layer() else { return }; + + let target_input = gradient_chain_target_input(output_layer, self.network_interface); + if let Some(node_id) = self.existing_proto_node_id_at(&target_input, graphene_std::math_nodes::color_value::IDENTIFIER, false) { + let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::color_value::ColorInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); + return; + } + + // The 'Color Value' node discards its primary input, so only a blank 'Merge' layer may start a chain with one, + // which any whole-expanse paint the other tool left behind is cleared off to become + let Some(paint_chain) = replaceable_paint_chain(output_layer, self.network_interface) else { + return; + }; + self.clear_paint_chain(&paint_chain); + + let color_value_id = self.insert_color_value(color, output_layer, paint_chain.attachment_input); + let input_connector = InputConnector::node(color_value_id, graphene_std::math_nodes::color_value::ColorInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); + } + /// Write the gradient stops to the 'Gradient Value' node feeding the layer. pub fn gradient_stops_set(&mut self, stops: Gradient) { let Some(output_layer) = self.get_output_layer() else { return }; @@ -513,11 +565,19 @@ impl<'a> ModifyInputsContext<'a> { let target = gradient_chain_target_input(output_layer, self.network_interface); let starts_layer_chain = target == InputConnector::layer_secondary_input(output_layer.to_node()); - // The Gradient Value node discards its primary input, so starting a chain ahead of existing layer content would drop that content; refuse instead - if starts_layer_chain && self.network_interface.upstream_output_connector(&target, &[]).is_some() { - log::error!("Refusing to start a gradient chain ahead of existing layer content"); - return; - } + // The 'Gradient Value' node discards its primary input, so only a blank 'Merge' layer may start a chain + // with one, which any whole-expanse paint the other tool left behind is cleared off to become + let paint_chain = if starts_layer_chain { + let Some(paint_chain) = replaceable_paint_chain(output_layer, self.network_interface) else { + log::error!("Refusing to start a gradient chain on anything but a blank 'Merge' layer"); + return; + }; + self.clear_paint_chain(&paint_chain); + + Some(paint_chain) + } else { + None + }; let Some(node_definition) = resolve_proto_node_type(graphene_std::math_nodes::gradient_value::IDENTIFIER) else { return; @@ -525,9 +585,9 @@ impl<'a> ModifyInputsContext<'a> { let node_id = NodeId::new(); self.network_interface.insert_node(node_id, node_definition.default_node_template(), &[]); - if starts_layer_chain { + if let Some(paint_chain) = paint_chain { // No Fill node: the new node starts the layer's chain - self.network_interface.move_node_to_chain_start(&node_id, output_layer, &[], self.import); + self.start_paint_chain(&node_id, output_layer, paint_chain.attachment_input); } else { // Feeding a Fill node's paint input: wire it up and place it one chain-width left and a step below the Fill self.network_interface.set_input(&target, NodeInput::node(node_id, 0), &[]); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index 65fee45b58..e1f5acf081 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -980,6 +980,13 @@ impl NodeNetworkInterface { let Some(feeder_position) = self.position(&feeder, network_path) else { return }; self.shift_node(node_id, feeder_position - node_position, network_path); + + // A chain feeder derives its position from its distance to the layer, which this insertion already grew, + // so shifting it would move it twice and cost it its place in the chain + if !self.is_absolute(&feeder, network_path) { + return; + } + // Deduplicate, since `UpstreamFlow` can yield a shared node more than once and we must shift each node only once. let upstream_nodes: HashSet = self.upstream_flow_back_from_nodes(vec![feeder], network_path, FlowType::UpstreamFlow).collect(); for upstream_node in &upstream_nodes { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index 98698effef..bf888f4ca5 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -897,6 +897,11 @@ impl NodeNetworkInterface { self.query(network_path, "is_artboard", |view| Ok(view.is_artboard(node_id))).unwrap_or_default() } + /// Whether the node is a Merge node by identity, meaning it is the generic layer wrapper rather than a specialized node displayed as a layer. + pub fn is_merge(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool { + self.query(network_path, "is_merge", |view| Ok(view.is_merge(node_id))).unwrap_or_default() + } + /// All artboard layers that participate in the scene, excluding disconnected Artboard nodes. pub fn all_artboards(&self) -> HashSet { // O(n * (nodes + wires)) since connected_to_output performs a graph walk per artboard candidate diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs index fafbeb35b5..6c99aca531 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs @@ -123,6 +123,14 @@ impl<'a, 'p> NetworkView<'a, 'p> { .is_some_and(|reference| reference == DefinitionIdentifier::Network("Artboard".into())) } + /// Whether the node is a Merge node by identity, regardless of whether it currently participates in the scene. + pub fn is_merge(&self, node_id: &NodeId) -> bool { + self.reference(node_id) + .ok() + .flatten() + .is_some_and(|reference| reference == DefinitionIdentifier::Network("Merge".into())) + } + /// The uneditable name in the Properties panel which represents the function name of the node implementation. pub fn implementation_name(&self, node_id: &NodeId) -> String { self.reference(node_id) 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 334802590b..7e169fe9dc 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -1,7 +1,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::{self, DefinitionIdentifier}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeNetworkInterface, NodeTemplate}; +use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeNetworkInterface, NodeTemplate, OutputConnector}; use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; @@ -285,15 +285,97 @@ pub fn gradient_chain_target_input(layer: LayerNodeIdentifier, network_interface } } -/// Try to find a "Gradient Value" node that is connected to a "Fill" node, or to a layer directly. -pub fn get_upstream_gradient_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { +/// Try to find the paint value node feeding a 'Fill' node, or a layer directly. +fn get_upstream_paint_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface, identifier: ProtoNodeIdentifier) -> Option { let target_input = gradient_chain_target_input(layer, network_interface); let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?; + let reference = DefinitionIdentifier::ProtoNode(identifier); 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::gradient_value::IDENTIFIER))) + .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&reference)) +} + +/// Try to find a 'Gradient Value' node that is connected to a 'Fill' node, or to a layer directly. +pub fn get_upstream_gradient_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + get_upstream_paint_value_node_id(layer, network_interface, graphene_std::math_nodes::gradient_value::IDENTIFIER) +} + +/// A whole-expanse paint to clear off a 'Merge' layer so a paint tool can start its own chain there. +pub struct ReplaceablePaintChain { + /// Where the fresh paint attaches, which is also the link severed to reach it. + pub attachment_input: InputConnector, + /// The paint's nodes, ordered downstream to upstream, empty when there is nothing to clear away. + pub nodes: Vec, +} + +/// The whole-expanse paint on a 'Merge' layer that a paint tool may take over, or `None` +/// when the layer isn't one or its chain holds anything the tools didn't put there. +/// The walk stops at the first node the rest of the graph also draws from, since repainting through it would change +/// what those other consumers see, and it passes through 'Transform' nodes so a swap keeps the layer's placement. +pub fn replaceable_paint_chain(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + if !network_interface.is_merge(&layer.to_node(), &[]) { + return None; + } + + // A generator discards its primary input, so the chain ends at whichever one is reached + let generators = [graphene_std::math_nodes::color_value::IDENTIFIER, graphene_std::math_nodes::gradient_value::IDENTIFIER]; + let setters = [ + graphene_std::math_nodes::gradient_form::IDENTIFIER, + graphene_std::math_nodes::gradient_spread::IDENTIFIER, + graphene_std::math_nodes::gradient_positions::IDENTIFIER, + graphene_std::math_nodes::gradient_midpoints::IDENTIFIER, + ]; + + let mut nodes = Vec::new(); + let mut input = InputConnector::layer_secondary_input(layer.to_node()); + // Trails the walk over the 'Transform' nodes that survive, then holds still once the paint to clear away begins + let mut attachment_input = input; + + while let Some(upstream) = network_interface.upstream_output_connector(&input, &[]) { + let node_id = upstream.node_id()?; + + // Arriving here means this layer consumes the node, so a further consumer makes it shared + let shared = network_interface + .with_outward_wires(&[], |outward_wires| outward_wires.get(&OutputConnector::primary_output(node_id)).is_some_and(|inputs| inputs.len() > 1)) + .unwrap_or(true); + if shared { + break; + } + + let Some(DefinitionIdentifier::ProtoNode(identifier)) = network_interface.reference(&node_id, &[]) else { + return None; + }; + + if identifier == graphene_std::transform_nodes::transform::IDENTIFIER { + input = InputConnector::primary_input(node_id); + if nodes.is_empty() { + attachment_input = input; + } + continue; + } + + let generator = generators.contains(&identifier); + if !generator && !setters.contains(&identifier) { + return None; + } + + nodes.push(node_id); + + if generator { + break; + } + + input = InputConnector::primary_input(node_id); + } + + Some(ReplaceablePaintChain { attachment_input, nodes }) +} + +/// Try to find a 'Color Value' node that is connected to a 'Fill' node, or to a layer directly. +pub fn get_upstream_color_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + get_upstream_paint_value_node_id(layer, network_interface, graphene_std::math_nodes::color_value::IDENTIFIER) } /// Get the node connected to Fill's fill input, if any. diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 4d95e0f3ed..09bf0bea6d 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,9 +1,13 @@ use super::tool_prelude::*; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface}; use crate::messages::tool::common_functionality::color_selector::solid; -use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_upstream_color_value_node_id, gradient_chain_target_input, replaceable_paint_chain}; use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; +use graphene_std::subpath::Subpath; +use graphene_std::vector::PointId; use graphene_std::vector::style::FillChoice; #[derive(Default, ExtractField)] @@ -140,10 +144,16 @@ impl Fsm for FillToolFsmState { let use_secondary = input.keyboard.get(Key::Shift as usize); let preview_color = if use_secondary { global_tool_data.secondary_color } else { global_tool_data.primary_color }; - // Get the layer the user is hovering over - if let Some(layer) = document.click(input, viewport) { + // Pattern the layer the fill would land on, over its whole expanse when the color is its entire content + if let Some(layer) = fill_target_layer(document, input, viewport) { let color_hex = SRGBA8::from(preview_color).to_css_hex(); - overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); + + if paints_whole_expanse(layer, &document.network_interface) { + let expanse = whole_expanse_rect(layer, document, overlay_context.viewport.size().into_dvec2()); + overlay_context.fill_path_pattern(std::iter::once(expanse), DAffine2::IDENTITY, &color_hex); + } else { + overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); + } } self @@ -154,9 +164,11 @@ impl Fsm for FillToolFsmState { self } (FillToolFsmState::Ready, color_event) => { - let Some(layer_identifier) = document.click(input, viewport) else { - return self; - }; + let Some(layer_identifier) = fill_target_layer(document, input, viewport) else { return self }; + + // A whole-expanse color chain (existing, or newly started on a blank layer) routes to its 'Color Value' node; geometry gets its Fill set + let route_to_color_chain = routes_to_color_chain(layer_identifier, &document.network_interface); + // 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; @@ -168,10 +180,14 @@ impl Fsm for FillToolFsmState { }; responses.add(DocumentMessage::AddTransaction); - responses.add(GraphOperationMessage::FillColorSet { - layer: layer_identifier, - color: Some(color), - }); + if route_to_color_chain { + responses.add(GraphOperationMessage::ColorValueSet { layer: layer_identifier, color }); + } else { + responses.add(GraphOperationMessage::FillColorSet { + layer: layer_identifier, + color: Some(color), + }); + } FillToolFsmState::Filling } @@ -202,6 +218,43 @@ impl Fsm for FillToolFsmState { } } +/// Whether the fill lands on the layer's 'Color Value' node rather than on geometry's Fill, which includes taking over +/// a layer the Gradient tool painted, since a whole-expanse gradient gives way to a solid color. +fn routes_to_color_chain(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> bool { + get_upstream_color_value_node_id(layer, network_interface).is_some() || replaceable_paint_chain(layer, network_interface).is_some() +} + +/// The layer the fill acts on: the one under the cursor, or else a selected layer painted through a color chain, +/// since blank layers and whole-expanse colors render no clickable geometry. +fn fill_target_layer(document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, viewport: &ViewportMessageHandler) -> Option { + document.click(input, viewport).or_else(|| { + document + .network_interface + .selected_nodes() + .selected_visible_layers(&document.network_interface) + .find(|&layer| routes_to_color_chain(layer, &document.network_interface)) + }) +} + +/// Whether the color is the layer's whole content rather than paint applied to its geometry, meaning there is no +/// outline to pattern and the preview covers the layer's whole expanse instead. +fn paints_whole_expanse(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> bool { + gradient_chain_target_input(layer, network_interface) == InputConnector::layer_secondary_input(layer.to_node()) && routes_to_color_chain(layer, network_interface) +} + +/// The viewport-space area a whole-expanse color paints: the artboard containing the layer, since the color fills it, +/// or else the visible viewport for a layer living outside any artboard. +fn whole_expanse_rect(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, viewport_size: DVec2) -> Subpath { + let containing_artboard = layer + .ancestors(document.metadata()) + .find(|&ancestor| ancestor != LayerNodeIdentifier::ROOT_PARENT && document.network_interface.is_artboard(&ancestor.to_node(), &[])); + + match containing_artboard.and_then(|artboard| document.metadata().bounding_box_viewport(artboard)) { + Some([min, max]) => Subpath::new_rectangle(min, max), + None => Subpath::new_rectangle(DVec2::ZERO, viewport_size), + } +} + #[cfg(test)] mod test_fill { pub use crate::test_utils::test_prelude::*; @@ -262,4 +315,302 @@ mod test_fill { assert_eq!(fills.len(), 1); assert_eq!(SRGBA8::from(fills[0]), SRGBA8::from(Color::YELLOW)); } + + #[tokio::test] + async fn blank_layer_gets_whole_expanse_color() { + use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_color_value_node_id; + use graph_craft::document::NodeId; + use graph_craft::document::value::TaggedValue; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let document = editor.active_document(); + let color_value_id = get_upstream_color_value_node_id(layer, &document.network_interface).expect("the fill should start a Color Value chain"); + let color_input = document + .network_interface + .document_network() + .nodes + .get(&color_value_id) + .and_then(|node| node.input(graphene_std::math_nodes::color_value::ColorInput)) + .and_then(|input| input.as_value()); + assert!(matches!(color_input, Some(TaggedValue::Color(color)) if *color == Color::GREEN)); + } + + #[tokio::test] + async fn replaces_a_whole_expanse_gradient_with_a_solid_color() { + use crate::messages::tool::common_functionality::graph_modification_utils::{get_upstream_color_value_node_id, get_upstream_gradient_value_node_id}; + use graph_craft::document::NodeId; + use graph_craft::document::value::TaggedValue; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + // Paint the layer's whole expanse with a gradient, which the Fill tool then takes over + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + assert!(get_upstream_gradient_value_node_id(layer, &editor.active_document().network_interface).is_some()); + + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let document = editor.active_document(); + let color_value_id = get_upstream_color_value_node_id(layer, &document.network_interface).expect("the fill should start a Color Value chain"); + let color_input = document + .network_interface + .document_network() + .nodes + .get(&color_value_id) + .and_then(|node| node.input(graphene_std::math_nodes::color_value::ColorInput)) + .and_then(|input| input.as_value()); + assert!(matches!(color_input, Some(TaggedValue::Color(color)) if *color == Color::GREEN)); + + // The replaced gradient nodes are gone from the graph rather than left orphaned + let gradient_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER); + let leftover_gradient_nodes = document + .network_interface + .document_network() + .nodes + .keys() + .filter(|node_id| document.network_interface.reference(node_id, &[]).as_ref() == Some(&gradient_reference)) + .count(); + assert_eq!(leftover_gradient_nodes, 0, "the gradient it replaced should be deleted"); + } + + #[tokio::test] + async fn replacing_a_shared_gradient_leaves_it_for_its_other_layer() { + use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; + use crate::messages::tool::common_functionality::graph_modification_utils::{get_upstream_color_value_node_id, get_upstream_gradient_value_node_id}; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let painted_id = NodeId::new(); + let sharing_id = NodeId::new(); + for id in [painted_id, sharing_id] { + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id, + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + } + let (painted, sharing) = (LayerNodeIdentifier::new_unchecked(painted_id), LayerNodeIdentifier::new_unchecked(sharing_id)); + + // Give one layer a whole-expanse gradient, then wire that same 'Gradient Value' node into the other layer + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![painted_id] }).await; + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + let gradient_value_id = get_upstream_gradient_value_node_id(painted, &editor.active_document().network_interface).expect("the drag should start a gradient chain"); + editor + .active_document_mut() + .network_interface + .create_wire(&OutputConnector::primary_output(gradient_value_id), &InputConnector::layer_secondary_input(sharing_id), &[]); + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![painted_id] }).await; + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let document = editor.active_document(); + assert!( + get_upstream_color_value_node_id(painted, &document.network_interface).is_some(), + "the filled layer should get its own Color Value chain" + ); + assert!( + document.network_interface.document_network().nodes.contains_key(&gradient_value_id), + "a gradient the rest of the graph still draws from must not be deleted" + ); + assert_eq!( + get_upstream_gradient_value_node_id(sharing, &document.network_interface), + Some(gradient_value_id), + "the other layer should keep its gradient" + ); + } + + #[tokio::test] + async fn replacing_a_gradient_shared_through_a_transform_leaves_the_other_layer_alone() { + use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; + use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_node_id_with_direct_fill_input, get_upstream_color_value_node_id, get_upstream_gradient_value_node_id}; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + // An ellipse layer whose Fill is painted by the same chain that fills the blank layer's whole expanse + editor.drag_tool(ToolType::Ellipse, 0., 0., 100., 100., ModifierKeys::empty()).await; + let ellipse = editor.active_document().metadata().all_layers().next().unwrap(); + + let painted_id = NodeId::new(); + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: painted_id, + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let painted = LayerNodeIdentifier::new_unchecked(painted_id); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![painted_id] }).await; + + // Nudging leaves a 'Transform' node, and the gradient drag paints through it + editor + .handle_message(DocumentMessage::NudgeSelectedLayers { + delta_x: 10., + delta_y: 0., + resize: Key::Shift, + resize_opposite: Key::Alt, + }) + .await; + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + let gradient_value_id = get_upstream_gradient_value_node_id(painted, &editor.active_document().network_interface).expect("the drag should start a gradient chain"); + let shared_transform_id = editor + .active_document() + .network_interface + .upstream_output_connector(&InputConnector::layer_secondary_input(painted_id), &[]) + .and_then(|output| output.node_id()) + .expect("the nudge should leave a Transform node feeding the layer"); + + // Branch that same Transform into the ellipse's Fill, so both layers draw from it + let ellipse_fill_id = get_fill_node_id_with_direct_fill_input(ellipse, &editor.active_document().network_interface).expect("the ellipse should have a Fill node"); + editor.active_document_mut().network_interface.create_wire( + &OutputConnector::primary_output(shared_transform_id), + &InputConnector::node(ellipse_fill_id, graphene_std::vector::fill::FillInput), + &[], + ); + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![painted_id] }).await; + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let document = editor.active_document(); + assert!( + get_upstream_color_value_node_id(painted, &document.network_interface).is_some(), + "the filled layer should get its own Color Value chain" + ); + assert!( + document.network_interface.document_network().nodes.contains_key(&gradient_value_id), + "the gradient still painting the ellipse must not be deleted" + ); + assert_eq!( + document + .network_interface + .upstream_output_connector(&InputConnector::node(ellipse_fill_id, graphene_std::vector::fill::FillInput), &[]) + .and_then(|output| output.node_id()), + Some(shared_transform_id), + "the ellipse should keep being painted by the shared chain" + ); + assert_eq!( + get_upstream_gradient_value_node_id(ellipse, &document.network_interface), + Some(gradient_value_id), + "the ellipse's fill should still resolve to the gradient" + ); + } + + #[tokio::test] + async fn nudged_blank_layer_keeps_its_transform() { + use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; + use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_color_value_node_id; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + // Nudging an empty layer leaves a 'Transform' node in its chain, even though the layer still paints nothing + editor + .handle_message(DocumentMessage::NudgeSelectedLayers { + delta_x: 10., + delta_y: 0., + resize: Key::Shift, + resize_opposite: Key::Alt, + }) + .await; + let layer_content_input = InputConnector::layer_secondary_input(layer.to_node()); + let transform_id = editor + .active_document() + .network_interface + .upstream_output_connector(&layer_content_input, &[]) + .and_then(|output| output.node_id()) + .expect("the nudge should leave a Transform node feeding the layer"); + + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let network_interface = &editor.active_document().network_interface; + let color_value_id = get_upstream_color_value_node_id(layer, network_interface).expect("the fill should start a Color Value chain"); + assert_eq!( + network_interface.upstream_output_connector(&layer_content_input, &[]).and_then(|output| output.node_id()), + Some(transform_id), + "the Transform node should still feed the layer" + ); + assert_eq!( + network_interface + .upstream_output_connector(&InputConnector::primary_input(transform_id), &[]) + .and_then(|output| output.node_id()), + Some(color_value_id), + "the color should be painted through the preserved Transform node" + ); + } + + #[tokio::test] + async fn node_displayed_as_layer_gets_no_color_chain() { + use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_color_value_node_id; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + // A generator node displayed as a layer looks empty from the outside, but its secondary input is a parameter of its own + let rectangle = editor + .create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)) + .await; + let network_interface = &mut editor.active_document_mut().network_interface; + network_interface.set_to_node_or_layer(&rectangle, &[], true); + let layer = LayerNodeIdentifier::new(rectangle, network_interface); + network_interface.move_layer_to_stack(layer, LayerNodeIdentifier::ROOT_PARENT, 0, &[]); + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![rectangle] }).await; + assert!(editor.active_document().metadata().all_layers().any(|other| other == layer), "the node should sit in the layer stack"); + + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + + let document = editor.active_document(); + assert!( + get_upstream_color_value_node_id(layer, &document.network_interface).is_none(), + "only a 'Merge' layer may have a whole-expanse color chain started on it" + ); + } } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 7bb336bbae..b98b08782c 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -9,7 +9,8 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils::{ - self, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_gradient_stops, get_upstream_gradient_value_node_id, gradient_chain_target_input, reverse_direction_tooltip_description, + self, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_gradient_stops, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, + reverse_direction_tooltip_description, }; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use glam::DMat2; @@ -1437,14 +1438,14 @@ impl Fsm for GradientToolFsmState { GradientToolFsmState::Drawing { drag_hint: hint } } else { let document_mouse = document.metadata().document_to_viewport.inverse().transform_point2(mouse); - // List-based gradients render no geometry, so a click on empty canvas yields no layer. - // Fall back to a selected gradient list layer so the user can drag a fresh gradient line anywhere. + // List-based gradients and blank layers render no geometry, so a click on empty canvas yields no layer. + // Fall back to a selected gradient list layer, or one blank enough to take a fresh whole-expanse gradient. let selected_layer = document.click_based_on_position(document_mouse).or_else(|| { document .network_interface .selected_nodes() .selected_visible_layers(&document.network_interface) - .find(|&layer| get_gradient_stops(layer, &document.network_interface).is_some()) + .find(|&layer| get_gradient_stops(layer, &document.network_interface).is_some() || replaceable_paint_chain(layer, &document.network_interface).is_some()) }); // Apply the gradient to the selected layer @@ -1485,7 +1486,12 @@ impl Fsm for GradientToolFsmState { gradient_form: tool_options.gradient_form, gradient_spread: tool_options.gradient_spread, }, - GradientSource::Direct, + // A blank layer, or one holding only the other tool's paint, starts a whole-expanse gradient chain; a layer with content gets its Fill painted + if replaceable_paint_chain(layer, &document.network_interface).is_some() { + GradientSource::Chain + } else { + GradientSource::Direct + }, ), }; let mut selected_gradient = SelectedGradient::new(gradient, appearance, source, layer, document); @@ -2973,4 +2979,184 @@ mod test_gradient { .expect("the positions input should exist"); assert!(matches!(positions_input, NodeInput::Node { .. }), "the wired positions input must survive the baked write"); } + + #[tokio::test] + async fn drag_on_blank_layer_starts_gradient_chain() { + use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + + let document = editor.active_document(); + assert!( + get_upstream_gradient_value_node_id(layer, &document.network_interface).is_some(), + "the drag should start a gradient chain" + ); + let stops = get_gradient_stops(layer, &document.network_interface).expect("the new chain should resolve stops"); + assert_eq!(stops.len(), 2); + } + + #[tokio::test] + async fn replaces_a_whole_expanse_color_with_a_gradient() { + use crate::messages::tool::common_functionality::graph_modification_utils::{get_gradient_stops, get_upstream_color_value_node_id}; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + // Nudging leaves a 'Transform' node that must survive the swap, and the fill paints the whole expanse + editor + .handle_message(DocumentMessage::NudgeSelectedLayers { + delta_x: 10., + delta_y: 0., + resize: Key::Shift, + resize_opposite: Key::Alt, + }) + .await; + editor.select_primary_color(Color::GREEN).await; + editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; + assert!(get_upstream_color_value_node_id(layer, &editor.active_document().network_interface).is_some()); + + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + + let document = editor.active_document(); + assert!( + get_upstream_gradient_value_node_id(layer, &document.network_interface).is_some(), + "the drag should start a gradient chain" + ); + assert_eq!(get_gradient_stops(layer, &document.network_interface).expect("the new chain should resolve stops").len(), 2); + assert!( + get_upstream_color_value_node_id(layer, &document.network_interface).is_none(), + "the color it replaced should be gone from the chain" + ); + + // The layer's own placement is not the paint's, so nudging survives a swap + let transform_reference = DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER); + let chain_transforms = document + .network_interface + .upstream_flow_back_from_nodes(vec![layer.to_node()], &[], super::FlowType::HorizontalFlow) + .filter(|node_id| document.network_interface.reference(node_id, &[]).as_ref() == Some(&transform_reference)) + .count(); + assert_eq!(chain_transforms, 1, "the layer's Transform node should survive the swap"); + } + + #[tokio::test] + async fn chain_started_past_a_transform_sits_beside_it() { + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let layer_id = NodeId::new(); + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: layer_id, + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = LayerNodeIdentifier::new_unchecked(layer_id); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer_id] }).await; + // Sitting away from where fresh nodes are dropped, so the chain's placement can't coincide with it + editor.active_document_mut().network_interface.shift_node(&layer_id, IVec2::new(20, 4), &[]); + + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + let transform_id = editor + .active_document() + .network_interface + .upstream_output_connector(&InputConnector::layer_secondary_input(layer_id), &[]) + .and_then(|output| output.node_id()) + .expect("the drag's placement should leave a Transform node feeding the layer"); + + // Each chain node sits one chain width left of the one it feeds, so the new value node lands beside the Transform + let network_interface = &editor.active_document().network_interface; + let gradient_value_id = get_upstream_gradient_value_node_id(layer, network_interface).expect("the drag should start a gradient chain"); + let layer_position = network_interface.position(&layer_id, &[]).expect("the layer should have a position"); + assert_eq!(network_interface.position(&transform_id, &[]), Some(layer_position - IVec2::new(crate::consts::NODE_CHAIN_WIDTH, 0))); + assert_eq!( + network_interface.position(&gradient_value_id, &[]), + Some(layer_position - IVec2::new(2 * crate::consts::NODE_CHAIN_WIDTH, 0)) + ); + assert!(network_interface.is_chain(&gradient_value_id, &[]), "the value node should stay part of the layer's chain"); + } + + #[tokio::test] + async fn drag_on_nudged_blank_layer_reuses_its_transform() { + use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops; + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + // Nudging an empty layer leaves a 'Transform' node in its chain, which the gradient then adopts as its placement + editor + .handle_message(DocumentMessage::NudgeSelectedLayers { + delta_x: 10., + delta_y: 0., + resize: Key::Shift, + resize_opposite: Key::Alt, + }) + .await; + let layer_content_input = InputConnector::layer_secondary_input(layer.to_node()); + let transform_id = editor + .active_document() + .network_interface + .upstream_output_connector(&layer_content_input, &[]) + .and_then(|output| output.node_id()) + .expect("the nudge should leave a Transform node feeding the layer"); + + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + + let network_interface = &editor.active_document().network_interface; + let gradient_value_id = get_upstream_gradient_value_node_id(layer, network_interface).expect("the drag should start a gradient chain"); + assert_eq!(get_gradient_stops(layer, network_interface).expect("the new chain should resolve stops").len(), 2); + + // The gradient paints through the nudge's Transform node rather than adding a second one + let transform_reference = DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER); + let chain_transforms = network_interface + .upstream_flow_back_from_nodes(vec![layer.to_node()], &[], super::FlowType::HorizontalFlow) + .filter(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&transform_reference)) + .count(); + assert_eq!(chain_transforms, 1, "the gradient should adopt the existing Transform node"); + assert_eq!( + network_interface + .upstream_output_connector(&InputConnector::primary_input(transform_id), &[]) + .and_then(|output| output.node_id()), + Some(gradient_value_id), + "the gradient should be painted through the preserved Transform node" + ); + } }