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
This commit is contained in:
Keavon Chambers
2026-08-04 18:32:15 -07:00
committed by GitHub
parent 788fe227c6
commit 9b3d07dc51
9 changed files with 739 additions and 31 deletions

View File

@@ -20,6 +20,10 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier,
color: Option<Color>,
},
ColorValueSet {
layer: LayerNodeIdentifier,
color: Color,
},
FillGradientSet {
layer: LayerNodeIdentifier,
#[serde(skip)]

View File

@@ -39,6 +39,11 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> 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,
@@ -323,7 +328,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> 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);
}

View File

@@ -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;
@@ -232,14 +234,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<Color>, layer: LayerNodeIdentifier) {
@@ -502,6 +531,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 };
@@ -512,11 +564,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");
// 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;
@@ -524,9 +584,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), &[]);

View File

@@ -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<NodeId> = self.upstream_flow_back_from_nodes(vec![feeder], network_path, FlowType::UpstreamFlow).collect();
for upstream_node in &upstream_nodes {

View File

@@ -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<LayerNodeIdentifier> {
// O(n * (nodes + wires)) since connected_to_output performs a graph walk per artboard candidate

View File

@@ -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)

View File

@@ -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::ProtoNodeIdentifier;
@@ -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<NodeId> {
/// 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<NodeId> {
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<NodeId> {
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<NodeId>,
}
/// 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<ReplaceablePaintChain> {
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<NodeId> {
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.

View File

@@ -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,11 +144,17 @@ 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();
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);
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<LayerNodeIdentifier> {
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<PointId> {
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 {
let color = fills.first().unwrap().element();
assert_eq!(SRGBA8::from(*color), 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"
);
}
}

View File

@@ -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);
@@ -2976,4 +2982,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"
);
}
}