Add the 'Basic Brush' node and replace the legacy Brush tool implementation with it (#4469)

* Draw raster images with pad extension instead of repeat

* Add the GPU basic brush renderer

* Rework the brush tool around the GPU basic brush

* Remove the CPU brush implementation
This commit is contained in:
Timon
2026-08-27 15:19:02 +00:00
committed by GitHub
parent fd31f2d89b
commit c7a64fff0c
31 changed files with 2181 additions and 1016 deletions

View File

@@ -135,7 +135,9 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;
// BRUSH TOOL
pub const BRUSH_SIZE_CHANGE_KEYBOARD: f64 = 5.;
pub const DEFAULT_BRUSH_SIZE: f64 = 20.;
pub const BRUSH_SIZE_DEFAULT: f64 = 40.;
pub const BRUSH_HARDNESS_DEFAULT: f64 = 0.;
pub const BRUSH_FLOW_DEFAULT: f64 = 100.;
// EYEDROPPER TOOL
pub const EYEDROPPER_PREVIEW_AREA_RESOLUTION: u32 = 11;

View File

@@ -275,11 +275,7 @@ impl PreferencesDialogMessageHandler {
let checkbox_id = CheckboxId::new();
let brush_tool_description = "
Enable the Brush tool to support basic raster-based layer painting.\n\
\n\
This legacy experimental tool has performance and quality limitations and is slated for replacement in future versions of Graphite that will have a renewed focus on raster graphics editing.\n\
\n\
Content created with the Brush tool may not be compatible with future versions of Graphite.\n\
Enable the Brush tool to support basic layer painting.\n\
\n\
*Default: Off.*
"

View File

@@ -1066,7 +1066,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
(true, Some(storage)) => storage
.export_to_bytes(
document_format::ExportFormat::Xz,
document_format::ExportOptions::default(),
document_format::ExportOptions {
include_history: false,
..Default::default()
},
export_load_handle.as_ref(),
Some(&legacy_document),
)

View File

@@ -5,7 +5,6 @@ use crate::messages::prelude::*;
use glam::{DAffine2, DVec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TypesettingConfig};
@@ -112,9 +111,23 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier,
modification_type: VectorModificationType,
},
Brush {
NewBrushGroupLayer {
id: NodeId,
strokes_node_id: NodeId,
parent: LayerNodeIdentifier,
insert_index: usize,
color: Color,
diameter: f64,
hardness: f64,
flow: f64,
},
NewBrushStrokesNode {
layer: LayerNodeIdentifier,
strokes: Vec<BrushStroke>,
strokes_node_id: NodeId,
color: Color,
diameter: f64,
hardness: f64,
flow: f64,
},
SetUpstreamToChain {
layer: LayerNodeIdentifier,

View File

@@ -2,13 +2,14 @@ use super::transform_utils;
use super::utility_types::{ModifyInputsContext, set_stroke_paint_order};
use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{BLEND_PATH_INPUT_INDEX, DefinitionIdentifier};
use crate::messages::portfolio::document::node_graph::document_node_definitions::{BLEND_PATH_INPUT_INDEX, DefinitionIdentifier, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector};
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::get_clip_mode;
use glam::{DAffine2, DVec2, IVec2};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graph_craft::list;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
@@ -172,10 +173,35 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.vector_modify(modification_type);
}
}
GraphOperationMessage::Brush { layer, strokes } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.brush_modify(strokes);
}
GraphOperationMessage::NewBrushGroupLayer {
id,
strokes_node_id,
parent,
insert_index,
color,
diameter,
hardness,
flow,
} => {
let layer = ModifyInputsContext::new(network_interface, responses).create_layer(id);
insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow);
responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index });
responses.add(GraphOperationMessage::SetUpstreamToChain { layer });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::NewBrushStrokesNode {
layer,
strokes_node_id,
color,
diameter,
hardness,
flow,
} => {
insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow);
responses.add(GraphOperationMessage::SetUpstreamToChain { layer });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::SetUpstreamToChain { layer } => {
let Some(OutputConnector::Node { node_id: first_chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::layer_secondary_input(layer.to_node()), &[]) else {
@@ -847,6 +873,22 @@ fn import_usvg_node_inner(
}
}
fn insert_brush_strokes_chain(network_interface: &mut NodeNetworkInterface, layer: LayerNodeIdentifier, strokes_node_id: NodeId, color: Color, diameter: f64, hardness: f64, flow: f64) {
let Some(strokes_node_type) = resolve_proto_node_type(graphene_std::brush::brush_strokes::IDENTIFIER) else {
log::error!("Brush strokes node does not exist");
return;
};
let strokes_node = strokes_node_type.node_template_input_override([
Some(NodeInput::value(TaggedValue::Strokes(Vec::new()), false)),
Some(NodeInput::value(TaggedValue::Color(color), false)),
Some(NodeInput::value(TaggedValue::F64(diameter), false)),
Some(NodeInput::value(TaggedValue::F64(hardness), false)),
Some(NodeInput::value(TaggedValue::F64(flow), false)),
]);
network_interface.insert_node(strokes_node_id, strokes_node, &[]);
network_interface.set_input(&InputConnector::node_at_index(layer.to_node(), 1), NodeInput::node(strokes_node_id, 0), &[]);
}
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, gradient_info: &SvgGradientInfo) {
let bezpath = convert_usvg_path(path);

View File

@@ -13,7 +13,6 @@ use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graph_craft::{ProtoNodeIdentifier, list};
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TypesettingConfig};
@@ -977,17 +976,6 @@ impl<'a> ModifyInputsContext<'a> {
self.responses.add(NodeGraphMessage::RunDocumentGraph);
}
pub fn brush_modify(&mut self, strokes: Vec<BrushStroke>) {
let Some(brush_node_id) = self.existing_proto_node_id(graphene_std::brush::brush::brush::IDENTIFIER, true) else {
return;
};
self.set_input_with_refresh(
InputConnector::node(brush_node_id, graphene_std::brush::brush::brush::TraceInput),
NodeInput::value(TaggedValue::BrushStrokes(strokes), false),
false,
);
}
pub fn resize_artboard(&mut self, location: DVec2, dimensions: DVec2) {
let Some(artboard_node_id) = self.existing_network_node_id("Artboard", true) else {
return;

View File

@@ -17,7 +17,6 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::{Type, concrete};
use graphene_std::animation::RealTimeMode;
use graphene_std::brush::brush_stroke::BrushTrace;
use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY;
use graphene_std::raster::{
@@ -286,7 +285,6 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<DAffine2>(x) => transform_widget(default_info, &mut extra_widgets),
Some(x) if id_is::<Color>(x) => color_widget(default_info, ColorInput::default().allow_none(false)),
Some(x) if id_is::<Gradient>(x) => color_widget(default_info, ColorInput::default().allow_none(false)),
Some(x) if id_is::<BrushTrace>(x) => brush_strokes_widget(default_info).into(),
// ============
// STRUCT TYPES
// ============
@@ -460,33 +458,6 @@ pub fn vector_modification_widget(parameter_widgets_info: ParameterWidgetsInfo)
widgets
}
pub fn brush_strokes_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<WidgetInstance> {
let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info;
let mut widgets = start_widgets(&parameter_widgets_info);
let Some(document_node) = document_node else { return widgets };
let Some(input) = document_node.inputs.get(index) else { return widgets };
if let Some(TaggedValue::BrushStrokes(strokes)) = input.as_non_exposed_value() {
let stroke_count = strokes.len();
let sample_count: usize = strokes.iter().map(|s| s.trace.len()).sum();
let label = if stroke_count == 0 {
"Empty".to_string()
} else {
format!(
"{stroke_count} {} / {sample_count} {}",
if stroke_count == 1 { "Stroke" } else { "Strokes" },
if sample_count == 1 { "Sample" } else { "Samples" }
)
};
widgets.extend_from_slice(&[Separator::new(SeparatorStyle::Unrelated).widget_instance(), TextLabel::new(label).widget_instance()]);
}
widgets
}
pub fn image_data_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<WidgetInstance> {
let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info;

View File

@@ -76,25 +76,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
aliases: &["graphene_core::raster::OpacityNode", "graphene_core::blending_nodes::OpacityNode"],
},
// ================================
// brush
// ================================
NodeReplacement {
node: graphene_std::brush::brush::blit::IDENTIFIER,
aliases: &["graphene_brush::BlitNode", "graphene_std::brush::BlitNode", "graphene_brush::brush::BlitNode"],
},
NodeReplacement {
node: graphene_std::brush::brush::brush::IDENTIFIER,
aliases: &["graphene_brush::BrushNode", "graphene_std::brush::BrushNode", "graphene_brush::brush::BrushNode"],
},
NodeReplacement {
node: graphene_std::brush::brush::brush_stamp_generator::IDENTIFIER,
aliases: &[
"graphene_brush::BrushStampGeneratorNode",
"graphene_std::brush::BrushStampGeneratorNode",
"graphene_brush::brush::BrushStampGeneratorNode",
],
},
// ================================
// gcore
// ================================
NodeReplacement {
@@ -1189,30 +1170,6 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
}
}
// The "Brush" wrapper network was replaced with the `brush` proto node directly. Convert old `Network("Brush")` instances to the proto node, forwarding all 3 inputs (Background, Trace, Cache) one-to-one.
// This must run as a pre-pass before the recursive iteration below: replacing the outer Brush's network impl orphans its child paths, and the recursive iteration would log errors for those stale paths.
let brush_layers: Vec<(NodeId, Vec<NodeId>)> = document
.network_interface
.document_network()
.recursive_nodes()
.filter_map(|(node_id, _, path)| (document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::Network("Brush".into()))).then_some((*node_id, path)))
.collect();
for (node_id, network_path) in &brush_layers {
// Pre-load `outward_wires` so the chain-break check inside `set_input` resolves the original upstream→node wire from cache
// rather than triggering a fresh rebuild from the (already-mutated) post-`replace_inputs` state, which would orphan wires.
let _ = document.network_interface.outward_wires(network_path);
let new_reference = DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER);
let Some(definition) = resolve_document_node_type(&new_reference) else { continue };
let mut node_template = definition.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else {
continue;
};
for (index, input) in old_inputs.iter().take(3).enumerate() {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
}
// The "Transform" wrapper network was replaced with the `transform` proto node directly. Convert old `Network("Transform")` instances to the proto node, forwarding the 5 user-facing inputs (Value, Translation, Rotation, Scale, Skew) and dropping the legacy migration sentinels (Origin Offset, Scale Appearance) at indices 5 and 6 if present.
// Pre-pass for the same reason as the Brush migration above: replacing the outer Transform's network impl orphans its child paths.
let transform_layers: Vec<(NodeId, Vec<NodeId>, usize)> = document
@@ -2229,36 +2186,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path);
}
// Old shape: [background, bounds, trace, cache]. Both "bounds" (input 1) and "cache" (input 3) are dropped, and "cache" is now stored as
// internal node state via `#[data]` on the brush node, so it is not a node input at all in the new shape.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 4 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[2].clone(), network_path);
}
// Old shape: [background, trace, cache]. The "cache" input is dropped because the brush node now stores its cache as internal `#[data]` state.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 3 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path);
}
// A brush node saved before `Item<Raster<CPU>>` had a default stored its unconnected background as the invalid `()`,
// which fails type resolution against the raster primary; adopt the definition's empty-raster default instead.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::None)) {
let default_background = resolve_document_node_type(&reference)?.node_template.inputs.first()?.clone();
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), default_background, network_path);
}
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::RemoveHandlesNode")) {
let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::auto_tangents::IDENTIFIER))?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);

View File

@@ -93,6 +93,7 @@ impl MessageHandler<PreferencesMessage, PreferencesMessageContext<'_>> for Prefe
zoom_with_scroll: self.zoom_with_scroll,
});
responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale });
responses.add(ToolMessage::RefreshToolShelf);
}
PreferencesMessage::ResetToDefaults => {
responses.add(PreferencesMessage::Load { preferences: Self::default() });

View File

@@ -1,27 +1,21 @@
use super::tool_prelude::*;
use crate::consts::DEFAULT_BRUSH_SIZE;
use crate::application::generate_uuid;
use crate::consts::{BRUSH_FLOW_DEFAULT, BRUSH_HARDNESS_DEFAULT, BRUSH_SIZE_DEFAULT};
use crate::messages::portfolio::document::graph_operation::transform_utils::get_current_transform;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::FlowType;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid};
use graph_craft::document::NodeId;
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, OutputConnector};
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, selection_changed_since_last_sync, solid};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graphene_std::Color;
use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle};
use graphene_std::brush::basic_brush::basic_brush as active_brush;
use graphene_std::brush::{Channel, Stroke};
use graphene_std::color::SRGBA8;
use graphene_std::raster::BlendMode;
use graphene_std::vector::style::FillChoice;
const BRUSH_MAX_SIZE: f64 = 5000.;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DrawMode {
Draw = 0,
Erase,
Restore,
}
const SAMPLE_MERGE_DISTANCE: f64 = 1.;
const SAMPLE_MERGE_PRESSURE: f64 = 0.01;
#[derive(Default, ExtractField)]
pub struct BrushTool {
@@ -34,32 +28,35 @@ pub struct BrushOptions {
diameter: f64,
hardness: f64,
flow: f64,
spacing: f64,
color: ToolColorOptions,
blend_mode: BlendMode,
draw_mode: DrawMode,
last_synced_selection: Vec<LayerNodeIdentifier>,
}
impl Default for BrushOptions {
fn default() -> Self {
Self {
diameter: DEFAULT_BRUSH_SIZE,
hardness: 0.,
flow: 100.,
spacing: 20.,
diameter: BRUSH_SIZE_DEFAULT,
hardness: BRUSH_HARDNESS_DEFAULT,
flow: BRUSH_FLOW_DEFAULT,
color: ToolColorOptions::default(),
blend_mode: BlendMode::Normal,
draw_mode: DrawMode::Draw,
last_synced_selection: Vec::new(),
}
}
}
impl BrushOptions {
fn active_color(&self) -> Color {
self.color.active_color().unwrap_or_default()
}
}
#[impl_message(Message, ToolMessage, Brush)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum BrushToolMessage {
// Standard messages
Abort,
SelectionChanged,
WorkingColorChanged,
// Tool-specific messages
@@ -72,14 +69,11 @@ pub enum BrushToolMessage {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum BrushToolMessageOptionsUpdate {
BlendMode(BlendMode),
ChangeDiameter(f64),
Color(Option<Color>),
Diameter(f64),
DrawMode(DrawMode),
Flow(f64),
Hardness(f64),
Spacing(f64),
Flow(f64),
WorkingColorsChanged,
}
@@ -104,7 +98,7 @@ impl ToolMetadata for BrushTool {
impl LayoutHolder for BrushTool {
fn layout(&self) -> Layout {
let mut widgets = vec![
let widgets = vec![
ColorInput::new(FillChoice::<SRGBA8>::from(self.options.color.fill_choice.as_ref().unwrap_or(&FillChoice::None)))
.mixed(self.options.color.fill_choice.is_none())
.narrow(true)
@@ -119,9 +113,13 @@ impl LayoutHolder for BrushTool {
NumberInput::new(Some(self.options.diameter))
.label("Diameter")
.min(1.)
.max(BRUSH_MAX_SIZE) /* Anything bigger would cause the application to be unresponsive and eventually die */
.unit(" px")
.on_update(|number_input: &NumberInput| BrushToolMessage::UpdateOptions { options: BrushToolMessageOptionsUpdate::Diameter(number_input.value.unwrap()) }.into())
.on_update(|number_input: &NumberInput| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::Diameter(number_input.value.unwrap()),
}
.into()
})
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(Some(self.options.hardness))
@@ -151,63 +149,8 @@ impl LayoutHolder for BrushTool {
.into()
})
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(Some(self.options.spacing))
.label("Spacing")
.min(1.)
.max(100.)
.mode_range()
.unit("%")
.on_update(|number_input: &NumberInput| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::Spacing(number_input.value.unwrap()),
}
.into()
})
.widget_instance(),
];
widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
let draw_mode_entries: Vec<_> = [DrawMode::Draw, DrawMode::Erase, DrawMode::Restore]
.into_iter()
.map(|draw_mode| {
RadioEntryData::new(format!("{draw_mode:?}")).label(format!("{draw_mode:?}")).on_update(move |_| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::DrawMode(draw_mode),
}
.into()
})
})
.collect();
widgets.push(RadioInput::new(draw_mode_entries).selected_index(Some(self.options.draw_mode as u32)).widget_instance());
widgets.push(Separator::new(SeparatorStyle::Related).widget_instance());
let blend_mode_entries: Vec<Vec<_>> = BlendMode::list()
.iter()
.map(|section| {
section
.iter()
.map(|blend_mode| {
MenuListEntry::new(format!("{blend_mode:?}")).label(blend_mode.to_string()).on_commit(|_| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::BlendMode(*blend_mode),
}
.into()
})
})
.collect()
})
.collect();
widgets.push(
DropdownInput::new(blend_mode_entries)
.selected_index(self.options.blend_mode.index_in_list().map(|index| index as u32))
.tooltip_description("The blend mode used with the background when performing a brush stroke. Only used in draw mode.")
.disabled(self.options.draw_mode != DrawMode::Draw)
.widget_instance(),
);
Layout(vec![LayoutGroup::row(widgets)])
}
}
@@ -215,12 +158,18 @@ impl LayoutHolder for BrushTool {
#[message_handler_data]
impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for BrushTool {
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, context: &mut ToolActionMessageContext<'a>) {
if matches!(&message, ToolMessage::Brush(BrushToolMessage::SelectionChanged)) {
if self.fsm_state == BrushToolFsmState::Ready && selection_changed_since_last_sync(&mut self.options.last_synced_selection, context.document) {
self.sync_options_from_selection(context.document, responses);
}
return;
}
let ToolMessage::Brush(BrushToolMessage::UpdateOptions { options }) = message else {
self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, true);
return;
};
match options {
BrushToolMessageOptionsUpdate::BlendMode(blend_mode) => self.options.blend_mode = blend_mode,
BrushToolMessageOptionsUpdate::ChangeDiameter(change) => {
let needs_rounding = ((self.options.diameter + change.abs() / 2.) % change.abs() - change.abs() / 2.).abs() > 0.5;
if needs_rounding && change > 0. {
@@ -234,12 +183,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Brus
self.send_layout(responses, LayoutTarget::ToolOptions);
}
BrushToolMessageOptionsUpdate::Diameter(diameter) => self.options.diameter = diameter,
BrushToolMessageOptionsUpdate::DrawMode(draw_mode) => self.options.draw_mode = draw_mode,
BrushToolMessageOptionsUpdate::Hardness(hardness) => self.options.hardness = hardness,
BrushToolMessageOptionsUpdate::Flow(flow) => self.options.flow = flow,
BrushToolMessageOptionsUpdate::Spacing(spacing) => self.options.spacing = spacing,
BrushToolMessageOptionsUpdate::Color(color) => {
// User picked a color: push to the global primary working color (no tool-local customization).
if let Some(color) = color {
responses.add(ToolMessage::SelectWorkingColor { color, primary: true });
}
@@ -273,30 +219,115 @@ impl ToolTransition for BrushTool {
fn event_to_message_map(&self) -> EventToMessageMap {
EventToMessageMap {
tool_abort: Some(BrushToolMessage::Abort.into()),
selection_changed: Some(BrushToolMessage::SelectionChanged.into()),
working_color_changed: Some(BrushToolMessage::WorkingColorChanged.into()),
..Default::default()
}
}
}
impl BrushTool {
fn sync_options_from_selection(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
let Some(strokes_node) = selected_strokes_node(document) else { return };
let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else {
return;
};
let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value());
if let Some(TaggedValue::F64(diameter)) = value(STROKES_DIAMETER_INPUT) {
self.options.diameter = *diameter;
}
if let Some(TaggedValue::F64(hardness)) = value(STROKES_HARDNESS_INPUT) {
self.options.hardness = *hardness;
}
if let Some(TaggedValue::F64(flow)) = value(STROKES_FLOW_INPUT) {
self.options.flow = *flow;
}
if let Some(TaggedValue::Color(color)) = value(STROKES_COLOR_INPUT)
&& *color != self.options.active_color()
{
responses.add(ToolMessage::SelectWorkingColor { color: *color, primary: true });
} else {
self.send_layout(responses, LayoutTarget::ToolOptions);
}
}
}
const STROKES_COLOR_INPUT: usize = 1;
const STROKES_DIAMETER_INPUT: usize = 2;
const STROKES_HARDNESS_INPUT: usize = 3;
const STROKES_FLOW_INPUT: usize = 4;
#[derive(Clone, Debug, Default)]
struct BrushToolData {
strokes: Vec<BrushStroke>,
stroke: Stroke,
stroke_node_id: Option<NodeId>,
strokes_before: Vec<Stroke>,
layer: Option<LayerNodeIdentifier>,
transform: DAffine2,
last_sample: (DVec2, Option<f64>),
}
enum BrushTarget {
Existing { strokes_node_id: NodeId, strokes: Vec<Stroke> },
NewGroup { parent: LayerNodeIdentifier, insert_index: usize },
FillEmpty { layer: LayerNodeIdentifier },
}
impl BrushToolData {
fn load_existing_strokes(&mut self, document: &DocumentMessageHandler) -> Option<LayerNodeIdentifier> {
self.transform = DAffine2::IDENTITY;
fn resolve_target(&mut self, document: &DocumentMessageHandler, options: &BrushOptions) -> Option<(LayerNodeIdentifier, BrushTarget)> {
self.layer = None;
if document.network_interface.selected_nodes().selected_layers(document.metadata()).count() != 1 {
let selected_nodes = document.network_interface.selected_nodes();
let mut selected_layers = selected_nodes.selected_layers(document.metadata());
let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?;
if self.load_brush_layer(document, selected_layer) {
return Some((
selected_layer,
BrushTarget::NewGroup {
parent: selected_layer,
insert_index: 0,
},
));
}
let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?;
if !self.load_brush_layer(document, parent) {
return None;
}
let layer = document.network_interface.selected_nodes().selected_layers(document.metadata()).next()?;
self.layer = Some(layer);
for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::HorizontalFlow) {
let Some(output) = document.network_interface.upstream_output_connector(&InputConnector::node_at_index(selected_layer.to_node(), 1), &[]) else {
return Some((parent, BrushTarget::FillEmpty { layer: selected_layer }));
};
let new_group = || {
let insert_index = parent.children(document.metadata()).position(|child| child == selected_layer).unwrap_or_default();
BrushTarget::NewGroup { parent, insert_index }
};
let OutputConnector::Node { node_id: strokes_node_id, .. } = output else {
return Some((parent, new_group()));
};
if document.network_interface.reference(&strokes_node_id, &[]) != Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER)) {
return Some((parent, new_group()));
}
let strokes = document
.network_interface
.document_network()
.nodes
.get(&strokes_node_id)
.and_then(|node| node.inputs.first())
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::Strokes(strokes) = value { Some(strokes.clone()) } else { None });
match strokes {
Some(strokes) if Self::style_matches(document, strokes_node_id, options) => Some((parent, BrushTarget::Existing { strokes_node_id, strokes })),
_ => Some((parent, new_group())),
}
}
fn load_brush_layer(&mut self, document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool {
self.transform = DAffine2::IDENTITY;
for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow) {
let Some(node) = document.network_interface.document_network().nodes.get(&node_id) else {
continue;
};
@@ -304,12 +335,9 @@ impl BrushToolData {
continue;
};
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && node_id != layer.to_node() {
let points_input = node.inputs.get(1)?;
let Some(TaggedValue::BrushStrokes(strokes)) = points_input.as_value() else { continue };
self.strokes = strokes.clone();
return Some(layer);
if reference == DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER) && node_id != candidate.to_node() {
self.layer = Some(candidate);
return true;
}
if reference == DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER) {
@@ -318,13 +346,39 @@ impl BrushToolData {
}
self.transform = DAffine2::IDENTITY;
None
false
}
fn update_strokes(&self, responses: &mut VecDeque<Message>) {
let Some(layer) = self.layer else { return };
let strokes = self.strokes.clone();
responses.add(GraphOperationMessage::Brush { layer, strokes });
fn style_matches(document: &DocumentMessageHandler, strokes_node: NodeId, options: &BrushOptions) -> bool {
let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else {
return false;
};
let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value());
matches!(value(STROKES_COLOR_INPUT), Some(TaggedValue::Color(color)) if *color == options.active_color())
&& matches!(value(STROKES_DIAMETER_INPUT), Some(TaggedValue::F64(diameter)) if *diameter == options.diameter)
&& matches!(value(STROKES_HARDNESS_INPUT), Some(TaggedValue::F64(hardness)) if *hardness == options.hardness)
&& matches!(value(STROKES_FLOW_INPUT), Some(TaggedValue::F64(flow)) if *flow == options.flow)
}
fn push_sample(&mut self, position: DVec2, pressure: Option<f64>, elapsed_milliseconds: f64) {
self.stroke.position.push(position);
if let Channel::Samples(times) = &mut self.stroke.time {
times.push(elapsed_milliseconds / 1000.);
}
if let Channel::Samples(pressures) = &mut self.stroke.pressure {
pressures.push(pressure.unwrap_or(1.) as f32);
}
}
fn update_stroke(&self, responses: &mut VecDeque<Message>) {
let Some(stroke_node_id) = self.stroke_node_id else { return };
let mut strokes = self.strokes_before.clone();
strokes.push(self.stroke.clone());
responses.add(NodeGraphMessage::SetInputValue {
node_id: stroke_node_id,
input_index: 0,
value: TaggedValue::Strokes(strokes).into(),
});
}
}
@@ -346,60 +400,96 @@ impl Fsm for BrushToolFsmState {
match (self, event) {
(BrushToolFsmState::Ready, BrushToolMessage::DragStart) => {
responses.add(DocumentMessage::StartTransaction);
let loaded_layer = tool_data.load_existing_strokes(document);
if let Some(layer) = loaded_layer {
let pos = document
.network_interface
.document_metadata()
.downstream_transform_to_viewport(layer)
.inverse()
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(pos);
let layer_document_scale = document.metadata().downstream_transform_to_viewport(layer) * tool_data.transform;
// TODO: Also scale it based on the input image ('Background' input).
// TODO: Resizing the input image results in a different brush size from the chosen diameter.
let layer_scale = 0.0001_f64 // Safety against division by zero
.max((layer_document_scale.matrix2 * glam::DVec2::X).length())
.max((layer_document_scale.matrix2 * glam::DVec2::Y).length());
// Start a new stroke with a single sample
let blend_mode = match tool_options.draw_mode {
DrawMode::Draw => tool_options.blend_mode,
DrawMode::Erase => BlendMode::Erase,
DrawMode::Restore => BlendMode::Restore,
};
tool_data.strokes.push(BrushStroke {
trace: vec![BrushInputSample { position: layer_position }],
style: BrushStyle {
color: tool_options.color.active_color().unwrap_or_default(),
diameter: tool_options.diameter / layer_scale,
hardness: tool_options.hardness,
flow: tool_options.flow,
spacing: tool_options.spacing,
blend_mode,
},
});
tool_data.update_strokes(responses);
BrushToolFsmState::Drawing
}
// Create the new layer, wait for the render output to return its transform, and then create the rest of the layer
else {
// A new brush layer needs a graph run before the stroke can start.
let Some((brush_layer, target)) = tool_data.resolve_target(document, tool_options) else {
new_brush_layer(document, responses);
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(DeferMessage::AfterGraphRun {
messages: vec![BrushToolMessage::DragStart.into()],
});
BrushToolFsmState::Ready
return BrushToolFsmState::Ready;
};
let pos = document
.network_interface
.document_metadata()
.downstream_transform_to_viewport(brush_layer)
.inverse()
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(pos);
let pressure = input.mouse.pressure;
tool_data.stroke = Stroke {
time: Channel::Samples(Vec::new()),
seed: generate_uuid(),
..Default::default()
};
if pressure.is_some() {
tool_data.stroke.pressure = Channel::Samples(Vec::new());
}
tool_data.push_sample(layer_position, pressure, input.mouse.time.unwrap_or(0.));
tool_data.last_sample = (input.mouse.position, pressure);
match target {
BrushTarget::Existing { strokes_node_id, strokes } => {
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = strokes;
}
BrushTarget::NewGroup { parent, insert_index } => {
let group_id = NodeId::new();
let strokes_node_id = NodeId::new();
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = Vec::new();
responses.add(GraphOperationMessage::NewBrushGroupLayer {
id: group_id,
strokes_node_id,
parent,
insert_index,
color: tool_options.active_color(),
diameter: tool_options.diameter,
hardness: tool_options.hardness,
flow: tool_options.flow,
});
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![group_id] });
}
BrushTarget::FillEmpty { layer } => {
let strokes_node_id = NodeId::new();
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = Vec::new();
responses.add(GraphOperationMessage::NewBrushStrokesNode {
layer,
strokes_node_id,
color: tool_options.active_color(),
diameter: tool_options.diameter,
hardness: tool_options.hardness,
flow: tool_options.flow,
});
}
}
tool_data.update_stroke(responses);
BrushToolFsmState::Drawing
}
(BrushToolFsmState::Drawing, BrushToolMessage::PointerMove) => {
if let Some(layer) = tool_data.layer
&& let Some(stroke) = tool_data.strokes.last_mut()
{
let pressure = input.mouse.pressure;
if pressure == Some(0.) {
return BrushToolFsmState::Drawing;
}
let (last_position, last_pressure) = tool_data.last_sample;
let moved = input.mouse.position.distance(last_position) >= SAMPLE_MERGE_DISTANCE;
let pressure_changed = match (pressure, last_pressure) {
(Some(pressure), Some(last_pressure)) => (pressure - last_pressure).abs() >= SAMPLE_MERGE_PRESSURE,
(pressure, last_pressure) => pressure.is_some() != last_pressure.is_some(),
};
if !moved && !pressure_changed {
return BrushToolFsmState::Drawing;
}
if let Some(layer) = tool_data.layer {
let layer_position = document
.network_interface
.document_metadata()
@@ -408,26 +498,31 @@ impl Fsm for BrushToolFsmState {
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(layer_position);
stroke.trace.push(BrushInputSample { position: layer_position })
tool_data.push_sample(layer_position, pressure, input.mouse.time.unwrap_or(0.));
tool_data.last_sample = (input.mouse.position, pressure);
}
tool_data.update_strokes(responses);
tool_data.update_stroke(responses);
BrushToolFsmState::Drawing
}
(BrushToolFsmState::Drawing, BrushToolMessage::DragStop) => {
if !tool_data.strokes.is_empty() {
if tool_data.stroke_node_id.is_some() {
responses.add(DocumentMessage::EndTransaction);
} else {
responses.add(DocumentMessage::AbortTransaction);
}
tool_data.strokes.clear();
tool_data.stroke_node_id = None;
tool_data.stroke = Stroke::default();
tool_data.strokes_before = Vec::new();
BrushToolFsmState::Ready
}
(BrushToolFsmState::Drawing, BrushToolMessage::Abort) => {
responses.add(DocumentMessage::AbortTransaction);
tool_data.strokes.clear();
tool_data.stroke_node_id = None;
tool_data.stroke = Stroke::default();
tool_data.strokes_before = Vec::new();
BrushToolFsmState::Ready
}
@@ -458,12 +553,40 @@ impl Fsm for BrushToolFsmState {
}
}
fn selected_strokes_node(document: &DocumentMessageHandler) -> Option<NodeId> {
let selected_nodes = document.network_interface.selected_nodes();
let mut selected_layers = selected_nodes.selected_layers(document.metadata());
let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?;
let group = if is_brush_layer(document, selected_layer) {
selected_layer.children(document.metadata()).next()?
} else {
let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?;
if !is_brush_layer(document, parent) {
return None;
}
selected_layer
};
let OutputConnector::Node { node_id, .. } = document.network_interface.upstream_output_connector(&InputConnector::node_at_index(group.to_node(), 1), &[])? else {
return None;
};
(document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER))).then_some(node_id)
}
fn is_brush_layer(document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool {
document
.network_interface
.upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow)
.any(|node_id| node_id != candidate.to_node() && document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER)))
}
fn new_brush_layer(document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
responses.add(DocumentMessage::DeselectAllLayers);
let brush_node = resolve_proto_node_type(graphene_std::brush::brush::brush::IDENTIFIER)
let brush_node = resolve_proto_node_type(active_brush::IDENTIFIER)
.expect("Brush node does not exist")
.default_node_template();
.node_template_input_override([None, Some(NodeInput::value(TaggedValue::BrushCache(Default::default()), false))]);
let id = NodeId::new();
responses.add(GraphOperationMessage::NewCustomLayer {