mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -371,16 +371,19 @@ name = "brush-nodes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"brush-types",
|
||||
"bytemuck",
|
||||
"core-types",
|
||||
"dyn-any",
|
||||
"glam",
|
||||
"graphene-hash",
|
||||
"graphic-types",
|
||||
"half",
|
||||
"node-macro",
|
||||
"raster-nodes",
|
||||
"raster-types",
|
||||
"serde",
|
||||
"tokio",
|
||||
"wgpu",
|
||||
"wgpu-executor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.*
|
||||
"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶meter_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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::DocumentNode;
|
||||
use crate::application_io::PlatformEditorApi;
|
||||
use crate::application_io::resource::Resource;
|
||||
use crate::proto::{Any as DAny, FutureAny};
|
||||
use brush_nodes::brush_stroke::{BrushStroke, BrushTrace};
|
||||
use brush_nodes::{BrushCache, Stroke};
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::list::{Item, List, NodeIdPath};
|
||||
@@ -94,10 +93,6 @@ macro_rules! tagged_value {
|
||||
/// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.)
|
||||
#[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")]
|
||||
GradientRamp(GradientRamp),
|
||||
/// Stored compactly as a `Vec<BrushStroke>`, materializes as the single-value `Item<BrushTrace>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
|
||||
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code
|
||||
#[serde(alias = "BrushStrokeTable")]
|
||||
BrushStrokes(Vec<BrushStroke>),
|
||||
Strokes(Vec<Stroke>),
|
||||
BrushCache(BrushCache),
|
||||
// =======================
|
||||
@@ -142,7 +137,6 @@ macro_rules! tagged_value {
|
||||
Self::DashPattern(lengths) => lengths.cache_hash(state),
|
||||
Self::BoxCorners(values) => values.cache_hash(state),
|
||||
Self::GradientRamp(ramp) => ramp.cache_hash(state),
|
||||
Self::BrushStrokes(strokes) => strokes.cache_hash(state),
|
||||
Self::Strokes(strokes) => strokes.cache_hash(state),
|
||||
Self::BrushCache(cache) => cache.cache_hash(state),
|
||||
// =======================
|
||||
@@ -207,7 +201,6 @@ macro_rules! tagged_value {
|
||||
Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))),
|
||||
Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))),
|
||||
Self::GradientRamp(ramp) => Box::new(Item::<Gradient>::from(ramp)),
|
||||
Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))),
|
||||
Self::Strokes(strokes) => {
|
||||
let list: List<Stroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
|
||||
Box::new(list)
|
||||
@@ -275,7 +268,6 @@ macro_rules! tagged_value {
|
||||
Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))),
|
||||
Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))),
|
||||
Self::GradientRamp(ramp) => Arc::new(Item::<Gradient>::from(ramp)),
|
||||
Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))),
|
||||
Self::Strokes(strokes) => {
|
||||
let list: List<Stroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
|
||||
Arc::new(list)
|
||||
@@ -309,7 +301,6 @@ macro_rules! tagged_value {
|
||||
Self::DashPattern(_) => item!(DashPattern),
|
||||
Self::BoxCorners(_) => item!(BoxCorners),
|
||||
Self::GradientRamp(_) => item!(Gradient),
|
||||
Self::BrushStrokes(_) => item!(BrushTrace),
|
||||
Self::Strokes(_) => list!(Stroke),
|
||||
Self::BrushCache(_) => item!(BrushCache),
|
||||
// =======================
|
||||
@@ -350,8 +341,6 @@ macro_rules! tagged_value {
|
||||
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(downcast::<Item<BoxCorners>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::<Gradient>(input).unwrap()))),
|
||||
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::<Item<Gradient>>(input).unwrap()))),
|
||||
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(downcast::<Item<BrushTrace>>(input).unwrap().into_element().0.iter_element_values().cloned().collect())),
|
||||
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(downcast::<List<Stroke>>(input).unwrap().into_iter().map(Item::into_element).collect())),
|
||||
x if x == TypeId::of::<Item<BrushCache>>() => Ok(TaggedValue::BrushCache(downcast::<Item<BrushCache>>(input).unwrap().into_element())),
|
||||
// =======================
|
||||
@@ -386,8 +375,6 @@ macro_rules! tagged_value {
|
||||
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<Item<BoxCorners>>().unwrap().element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Gradient>().unwrap()))),
|
||||
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Item<Gradient>>().unwrap()))),
|
||||
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Vec<BrushStroke>>().unwrap().clone())),
|
||||
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Item<BrushTrace>>().unwrap().element().0.iter_element_values().cloned().collect())),
|
||||
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(input.downcast_ref::<List<Stroke>>().unwrap().iter_element_values().cloned().collect())),
|
||||
x if x == TypeId::of::<Item<BrushCache>>() => Ok(TaggedValue::BrushCache(input.downcast_ref::<Item<BrushCache>>().unwrap().element().clone())),
|
||||
// =======================
|
||||
@@ -417,7 +404,6 @@ macro_rules! tagged_value {
|
||||
if name == std::any::type_name::<DashPattern>() { return Some(TaggedValue::DashPattern(Vec::new())) }
|
||||
if name == std::any::type_name::<BoxCorners>() { return Some(TaggedValue::BoxCorners(Vec::new())) }
|
||||
$( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )*
|
||||
if name == std::any::type_name::<BrushTrace>() { return Some(TaggedValue::BrushStrokes(Vec::new())) }
|
||||
if name == std::any::type_name::<List<Stroke>>() { return Some(TaggedValue::Strokes(Vec::new())) }
|
||||
if name == std::any::type_name::<BrushCache>() { return Some(TaggedValue::BrushCache(Default::default())) }
|
||||
// Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time
|
||||
@@ -475,7 +461,6 @@ macro_rules! tagged_value {
|
||||
Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"),
|
||||
Self::BoxCorners(values) => format!("BoxCorners({values:?})"),
|
||||
Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"),
|
||||
Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"),
|
||||
Self::Strokes(strokes) => format!("Strokes({strokes:?})"),
|
||||
Self::BrushCache(cache) => format!("{cache:?}"),
|
||||
// =======================
|
||||
|
||||
@@ -1059,7 +1059,7 @@ mod test {
|
||||
// If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them.
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)]
|
||||
vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ use graph_craft::proto::{NodeConstructor, TypeErasedBox};
|
||||
use graphene_std::animation::RealTimeMode;
|
||||
use graphene_std::any::DynAnyNode;
|
||||
use graphene_std::brush::Stroke;
|
||||
use graphene_std::brush::brush_stroke::BrushTrace;
|
||||
use graphene_std::extract_xy::XY;
|
||||
use graphene_std::gradient::Gradient;
|
||||
use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath};
|
||||
@@ -82,7 +81,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
||||
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<GradientInterpolation>]),
|
||||
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<AttributeValueDyn>]),
|
||||
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => ListDyn]),
|
||||
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<BrushTrace>]),
|
||||
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<graphene_std::brush::Stroke>]),
|
||||
// Context nullification
|
||||
#[cfg(feature = "gpu")]
|
||||
@@ -146,7 +144,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&PlatformEditorApi>]),
|
||||
#[cfg(feature = "gpu")]
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<Raster<GPU>>]),
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<BrushTrace>]),
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::brush::Stroke>]),
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<RenderIntermediate>]),
|
||||
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&wgpu_executor::WgpuExecutor>]),
|
||||
@@ -355,7 +352,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
||||
RedGreenBlueAlpha,
|
||||
RelativeAbsolute,
|
||||
SelectiveColorChoice,
|
||||
BrushTrace,
|
||||
Stroke,
|
||||
XY,
|
||||
ScaleType,
|
||||
|
||||
@@ -2414,7 +2414,7 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster<CPU>>, scene: &mut S
|
||||
height: image.height,
|
||||
alpha_type: peniko::ImageAlphaType::Alpha,
|
||||
})
|
||||
.with_extend(peniko::Extend::Repeat);
|
||||
.with_extend(peniko::Extend::Pad);
|
||||
|
||||
scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array()));
|
||||
|
||||
@@ -2562,7 +2562,7 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster<GPU>>, scene: &mut S
|
||||
height,
|
||||
alpha_type: peniko::ImageAlphaType::Alpha,
|
||||
})
|
||||
.with_extend(peniko::Extend::Repeat);
|
||||
.with_extend(peniko::Extend::Pad);
|
||||
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64));
|
||||
scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array()));
|
||||
context.resource_overrides.push((image, raster.texture.clone()));
|
||||
|
||||
@@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
serde = ["dep:serde", "core-types/serde", "raster-types/serde", "raster-nodes/serde"]
|
||||
serde = ["dep:serde", "core-types/serde", "raster-types/serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
@@ -17,12 +17,15 @@ brush-types = { workspace = true }
|
||||
core-types = { workspace = true }
|
||||
graphene-hash = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
raster-nodes = { workspace = true }
|
||||
raster-types = { workspace = true, features = ["wgpu"] }
|
||||
wgpu-executor = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
half = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
|
||||
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal file
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub(super) const SIGMA_CUTOFF: f32 = 3.;
|
||||
pub(super) const SIGMA_PER_DIAMETER: f64 = 1. / 4.;
|
||||
pub(super) const RIDGE_GAIN: f32 = 5.075688;
|
||||
|
||||
pub(super) const LUT_SIZE: u32 = 256;
|
||||
pub(super) const LUT_V_MAX: f64 = 7.5;
|
||||
pub(super) const LUT_T_MAX: f64 = 7.5;
|
||||
pub(super) const LUT_CACHE_SIZE: usize = 64;
|
||||
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal file
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
pub(super) struct Convert {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Convert {
|
||||
pub(super) fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("convert.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_convert_bind_group_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_convert_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_convert_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout }
|
||||
}
|
||||
|
||||
pub(super) fn encode(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, target: &wgpu::TextureView) {
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_convert_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
}],
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_convert_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal file
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal file
@@ -0,0 +1,37 @@
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
|
||||
let pos = array(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
return vec4f(pos[vertex_index], 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
@group(0) @binding(0)
|
||||
var t_composite: texture_2d<f32>;
|
||||
|
||||
fn linear_to_srgb(channel: f32) -> f32 {
|
||||
if (channel <= 0.0031308) {
|
||||
return channel * 12.92;
|
||||
}
|
||||
return 1.055 * pow(channel, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let premultiplied = textureLoad(t_composite, vec2<i32>(frag.xy), 0);
|
||||
var straight = vec3<f32>(0.0);
|
||||
if (premultiplied.a > 0.0) {
|
||||
straight = premultiplied.rgb / premultiplied.a;
|
||||
}
|
||||
return vec4<f32>(linear_to_srgb(straight.r), linear_to_srgb(straight.g), linear_to_srgb(straight.b), premultiplied.a);
|
||||
}
|
||||
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal file
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Brush kernel baking + caching.
|
||||
//!
|
||||
//! Kernel is a super-Gaussian `exp(-((v^2 + s^2) / 2)^p)`: p = 1 plain Gaussian, higher p
|
||||
//! flattens center + steepens edge. Hardness controls p. Sweep along a segment has no
|
||||
//! closed form, so baked numerically into a texture: row per perpendicular distance,
|
||||
//! columns accumulate the along-axis integral. Segment = two LUT samples,
|
||||
//! `F(v, t) - F(v, t - len)`. Normalized so a long stroke's interior settles at 1.
|
||||
//!
|
||||
//! Calibration defines diameter: find where resolved alpha crosses EDGE_ALPHA, scale so
|
||||
//! that contour lands on `diameter / 2`. Painted width matches the setting, hard or soft.
|
||||
//! p clamped so the edge stays >= MIN_EDGE_TEXELS on screen.
|
||||
//!
|
||||
//! Baked kernels: small LRU keyed by quantized p. Textures from the global pool, held
|
||||
//! weakly; evicted under pressure -> bake again.
|
||||
|
||||
use super::consts::{LUT_CACHE_SIZE, LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_PER_DIAMETER};
|
||||
use super::stroke::StyledStroke;
|
||||
use glam::UVec2;
|
||||
use raster_types::{Texture, TextureWeakRef};
|
||||
use std::sync::Mutex;
|
||||
use wgpu_executor::WgpuExecutor;
|
||||
|
||||
const INTEGRATE_END: f64 = 12.;
|
||||
const FINE_STEPS: usize = 4096;
|
||||
const MIN_EDGE_TEXELS: f64 = 1.5;
|
||||
|
||||
const EDGE_WIDTH_FACTOR: f64 = 3.09;
|
||||
|
||||
const KEY_STEPS_PER_LN: f64 = 24.;
|
||||
|
||||
const SOFTEST: f64 = 0.7;
|
||||
const HARDEST: f64 = 48.;
|
||||
|
||||
const EDGE_ALPHA: f64 = 0.05;
|
||||
|
||||
pub(super) struct Kernel {
|
||||
pub(super) texture: Texture,
|
||||
pub(super) scale: f32,
|
||||
pub(super) exponent: f32,
|
||||
pub(super) section_scale: f32,
|
||||
}
|
||||
|
||||
struct Baked {
|
||||
scale: f32,
|
||||
exponent: f32,
|
||||
section_scale: f32,
|
||||
texture: TextureWeakRef,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct KernelCache {
|
||||
entries: Mutex<Vec<(i32, Baked)>>,
|
||||
}
|
||||
|
||||
impl KernelCache {
|
||||
pub(super) fn get(&self, executor: &WgpuExecutor, stroke: &StyledStroke, scale: f64) -> Kernel {
|
||||
let sigma_texels = stroke.diameter.max(0.) * SIGMA_PER_DIAMETER * scale;
|
||||
let sharpest = (EDGE_WIDTH_FACTOR * sigma_texels / (2. * MIN_EDGE_TEXELS)).max(1.);
|
||||
let exponent = (SOFTEST * (HARDEST / SOFTEST).powf(stroke.hardness.clamp(0., 1.))).min(sharpest);
|
||||
let key = (exponent.ln() * KEY_STEPS_PER_LN).round() as i32;
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
if let Some(index) = entries.iter().position(|(cached, _)| *cached == key) {
|
||||
if let Some(texture) = entries[index].1.texture.upgrade() {
|
||||
let entry = entries.remove(index);
|
||||
let kernel = Kernel {
|
||||
texture,
|
||||
scale: entry.1.scale,
|
||||
exponent: entry.1.exponent,
|
||||
section_scale: entry.1.section_scale,
|
||||
};
|
||||
entries.insert(0, entry);
|
||||
return kernel;
|
||||
}
|
||||
entries.remove(index);
|
||||
}
|
||||
let kernel = bake(executor, (key as f64 / KEY_STEPS_PER_LN).exp());
|
||||
let baked = Baked {
|
||||
scale: kernel.scale,
|
||||
exponent: kernel.exponent,
|
||||
section_scale: kernel.section_scale,
|
||||
texture: kernel.texture.downgrade(),
|
||||
};
|
||||
entries.insert(0, (key, baked));
|
||||
entries.truncate(LUT_CACHE_SIZE);
|
||||
kernel
|
||||
}
|
||||
}
|
||||
|
||||
fn kernel(v: f64, s: f64, exponent: f64) -> f64 {
|
||||
(-((v * v + s * s) / 2.).powf(exponent)).exp()
|
||||
}
|
||||
|
||||
fn sweep_row(v: f64, exponent: f64) -> (Vec<f64>, f64) {
|
||||
let ds = 2. * INTEGRATE_END / FINE_STEPS as f64;
|
||||
let mut cumulative = Vec::with_capacity(FINE_STEPS + 1);
|
||||
let mut total = 0.;
|
||||
let mut previous = kernel(v, -INTEGRATE_END, exponent);
|
||||
cumulative.push(0.);
|
||||
for i in 1..=FINE_STEPS {
|
||||
let value = kernel(v, -INTEGRATE_END + i as f64 * ds, exponent);
|
||||
total += (previous + value) / 2. * ds;
|
||||
previous = value;
|
||||
cumulative.push(total);
|
||||
}
|
||||
let samples = (0..LUT_SIZE)
|
||||
.map(|j| {
|
||||
let t = -LUT_T_MAX + j as f64 * 2. * LUT_T_MAX / (LUT_SIZE - 1) as f64;
|
||||
let x = (t + INTEGRATE_END) / ds;
|
||||
let i = (x.floor() as usize).min(FINE_STEPS - 1);
|
||||
cumulative[i] + (cumulative[i + 1] - cumulative[i]) * (x - i as f64)
|
||||
})
|
||||
.collect();
|
||||
(samples, total)
|
||||
}
|
||||
|
||||
fn calibrate(ridge: &[f64], target: f64) -> f64 {
|
||||
let step = LUT_V_MAX / (LUT_SIZE - 1) as f64;
|
||||
let Some(i) = ridge.iter().position(|&r| r < target).filter(|&i| i > 0) else {
|
||||
return LUT_V_MAX;
|
||||
};
|
||||
let (above, below) = (ridge[i - 1], ridge[i]);
|
||||
step * ((i - 1) as f64 + (above - target) / (above - below))
|
||||
}
|
||||
|
||||
fn bake(executor: &WgpuExecutor, exponent: f64) -> Kernel {
|
||||
let mut rows = Vec::with_capacity((LUT_SIZE * LUT_SIZE) as usize);
|
||||
let mut ridge = Vec::with_capacity(LUT_SIZE as usize);
|
||||
let mut norm = 1.;
|
||||
for row in 0..LUT_SIZE {
|
||||
let v = row as f64 * LUT_V_MAX / (LUT_SIZE - 1) as f64;
|
||||
if kernel(v, 0., exponent) < 1e-9 {
|
||||
rows.resize(rows.len() + LUT_SIZE as usize, half::f16::ZERO);
|
||||
ridge.push(0.);
|
||||
continue;
|
||||
}
|
||||
let (samples, total) = sweep_row(v, exponent);
|
||||
if row == 0 {
|
||||
norm = 1. / total;
|
||||
}
|
||||
rows.extend(samples.into_iter().map(|value| half::f16::from_f64(value * norm)));
|
||||
ridge.push(total * norm);
|
||||
}
|
||||
let texture = executor.request_texture_with_format(UVec2::splat(LUT_SIZE), wgpu::TextureFormat::R16Float);
|
||||
executor.context().queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
bytemuck::cast_slice(&rows),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(LUT_SIZE * 2),
|
||||
rows_per_image: Some(LUT_SIZE),
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
let gain = RIDGE_GAIN as f64;
|
||||
let target = -(1. - EDGE_ALPHA * (1. - (-gain).exp())).ln() / gain;
|
||||
let a = calibrate(&ridge, target);
|
||||
Kernel {
|
||||
texture,
|
||||
scale: (a / 2.) as f32,
|
||||
exponent: exponent as f32,
|
||||
section_scale: ((2. * (1. / target).ln().powf(1. / exponent)).sqrt() / 2.) as f32,
|
||||
}
|
||||
}
|
||||
75
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal file
75
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod consts;
|
||||
mod convert;
|
||||
mod kernel;
|
||||
mod pipeline;
|
||||
mod region;
|
||||
mod render;
|
||||
mod stroke;
|
||||
|
||||
use brush_types::BrushCache;
|
||||
use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List};
|
||||
use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint};
|
||||
use graphic_types::Graphic;
|
||||
use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs};
|
||||
use raster_types::{GPU, Raster};
|
||||
use wgpu_executor::{WgpuExecutor, WgpuPipelineCache};
|
||||
|
||||
#[node_macro::node(category("Raster: Brush"))]
|
||||
pub async fn basic_brush<'a: 'n>(
|
||||
ctx: impl Ctx + ExtractFootprint,
|
||||
strokes: List<Graphic>,
|
||||
#[widget(ParsedWidgetOverride::Hidden)] cache: Item<BrushCache>,
|
||||
#[scope(basic_brush_pipeline::IDENTIFIER)] pipeline: Item<WgpuPipelineCache>,
|
||||
) -> List<Raster<GPU>> {
|
||||
let (cache, pipeline) = (cache.into_element(), pipeline.into_element());
|
||||
let mut stack = vec![strokes.into_iter()];
|
||||
let mut strokes = Vec::new();
|
||||
while let Some(top) = stack.last_mut() {
|
||||
let Some(item) = top.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let color = item.attribute_cloned_or(ATTR_COLOR, crate::DEFAULT_COLOR);
|
||||
let diameter = item.attribute_cloned_or(ATTR_DIAMETER, crate::DEFAULT_DIAMETER);
|
||||
let hardness = item.attribute_cloned_or(ATTR_HARDNESS, crate::DEFAULT_HARDNESS / 100.);
|
||||
let flow = item.attribute_cloned_or(ATTR_FLOW, crate::DEFAULT_FLOW / 100.);
|
||||
match item.into_element() {
|
||||
Graphic::StrokeList(list) => strokes.extend(
|
||||
list.into_iter()
|
||||
.map(Item::into_element)
|
||||
.filter(|stroke| !stroke.is_empty() && stroke.is_valid())
|
||||
.map(|stroke| stroke::StyledStroke {
|
||||
color,
|
||||
diameter,
|
||||
hardness,
|
||||
flow,
|
||||
stroke,
|
||||
}),
|
||||
),
|
||||
Graphic::Graphic(item) => stack.push(List::new_from_item(*item).into_iter()),
|
||||
Graphic::GraphicList(nested) => stack.push(nested.into_iter()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let args = BasicBrushPipelineArgs {
|
||||
footprint: *ctx.footprint(),
|
||||
strokes: &strokes,
|
||||
cache: &cache,
|
||||
};
|
||||
let Some((texture, transform)) = pipeline.run::<BasicBrushPipeline>(&args).await else {
|
||||
return List::new();
|
||||
};
|
||||
let raster = Raster::<GPU>::new_gpu(texture);
|
||||
List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform))
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""), inject_scope)]
|
||||
async fn basic_brush_pipeline<'a: 'n>(
|
||||
_ctx: impl Ctx,
|
||||
#[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: Item<&'a WgpuExecutor>,
|
||||
#[data] pipeline: WgpuPipelineCache,
|
||||
) -> Item<WgpuPipelineCache> {
|
||||
executor.into_element().pipeline_init::<BasicBrushPipeline>(pipeline);
|
||||
Item::new_from_element(pipeline.clone())
|
||||
}
|
||||
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal file
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal file
@@ -0,0 +1,543 @@
|
||||
use super::consts::{LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_CUTOFF};
|
||||
use super::convert::Convert;
|
||||
use super::kernel::{Kernel, KernelCache};
|
||||
use super::region::{Crop, Region};
|
||||
use super::stroke::{Edge, StyledStroke};
|
||||
use brush_types::BrushCache;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use core_types::Color;
|
||||
use core_types::transform::Footprint;
|
||||
use glam::{DAffine2, UVec2};
|
||||
use raster_types::Texture;
|
||||
use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor};
|
||||
|
||||
pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float;
|
||||
pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
struct ScatterUniforms {
|
||||
frame_size: [f32; 2],
|
||||
kernel_scale: f32,
|
||||
kernel_exponent: f32,
|
||||
kernel_section_scale: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
struct ResolveUniforms {
|
||||
color: [f32; 4],
|
||||
density_offset: [f32; 2],
|
||||
_pad: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct BasicBrushPipeline {
|
||||
scatter: Scatter,
|
||||
resolve: Resolve,
|
||||
convert: Convert,
|
||||
kernels: KernelCache,
|
||||
}
|
||||
|
||||
pub(super) struct Field {
|
||||
pub(super) density: Texture,
|
||||
pub(super) stamp: Texture,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub(super) fn request(executor: &WgpuExecutor, size: UVec2) -> Self {
|
||||
Self {
|
||||
density: executor.request_texture_with_format(size, DENSITY_FORMAT),
|
||||
stamp: executor.request_texture_with_format(size, DENSITY_FORMAT),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn views(&self) -> FieldViews {
|
||||
FieldViews {
|
||||
density: self.density.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
stamp: self.stamp.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct FieldViews {
|
||||
pub(super) density: wgpu::TextureView,
|
||||
pub(super) stamp: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct BasicBrushPipelineArgs<'a> {
|
||||
pub(super) footprint: Footprint,
|
||||
pub(super) strokes: &'a [StyledStroke],
|
||||
pub(super) cache: &'a BrushCache,
|
||||
}
|
||||
|
||||
impl AsyncWgpuPipeline for BasicBrushPipeline {
|
||||
type Args<'a> = BasicBrushPipelineArgs<'a>;
|
||||
type Out = Option<(Texture, DAffine2)>;
|
||||
|
||||
fn create(executor: &WgpuExecutor) -> Self {
|
||||
let device = &executor.context().device;
|
||||
Self {
|
||||
scatter: Scatter::new(device),
|
||||
resolve: Resolve::new(device),
|
||||
convert: Convert::new(device),
|
||||
kernels: KernelCache::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out {
|
||||
let frame = super::render::Frame::new(args.strokes)?;
|
||||
let region = Region::new(&args.footprint)?;
|
||||
let state = args.cache.take(&args.footprint).unwrap_or_default();
|
||||
let rendered = super::render::render(self, executor, frame, region, state)?;
|
||||
args.cache.store(&args.footprint, rendered.state);
|
||||
Some((rendered.texture, rendered.transform))
|
||||
}
|
||||
}
|
||||
|
||||
struct Scatter {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl Scatter {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("scatter.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_density_bind_group_layout"),
|
||||
entries: &[
|
||||
uniform_entry(0, wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("basic_brush_kernel_sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_density_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let instance_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Edge>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 8,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 16,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 20,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32,
|
||||
},
|
||||
],
|
||||
};
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let union = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Max,
|
||||
};
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &[("CUTOFF_SIGMA", SIGMA_CUTOFF as f64), ("LUT_SIZE", LUT_SIZE as f64), ("LUT_V_MAX", LUT_V_MAX), ("LUT_T_MAX", LUT_T_MAX)],
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_density_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[instance_layout],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: options,
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DENSITY_FORMAT,
|
||||
blend: Some(wgpu::BlendState { color: additive, alpha: additive }),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DENSITY_FORMAT,
|
||||
blend: Some(wgpu::BlendState { color: union, alpha: union }),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout, sampler }
|
||||
}
|
||||
|
||||
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, kernel: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_density_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(kernel),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &FieldViews, bind: &wgpu::BindGroup, buffer: &wgpu::Buffer, instances: u32) {
|
||||
let attachment = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_density_pass"),
|
||||
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..4, 0..instances);
|
||||
}
|
||||
}
|
||||
|
||||
struct Resolve {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Resolve {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("resolve.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_resolve_bind_group_layout"),
|
||||
entries: &[uniform_entry(0, wgpu::ShaderStages::FRAGMENT), texture_entry(1), texture_entry(2)],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_resolve_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &[("RIDGE_GAIN", RIDGE_GAIN as f64), ("RIDGE_NORM", 1. / (1. - (-RIDGE_GAIN as f64).exp()))],
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_resolve_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: COMPOSITE_FORMAT,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout }
|
||||
}
|
||||
|
||||
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, source: &FieldViews) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_resolve_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&source.density),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&source.stamp),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, bind: &wgpu::BindGroup, scissor: (UVec2, UVec2)) {
|
||||
let (origin, size) = scissor;
|
||||
if !size.cmpgt(UVec2::ZERO).all() {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_resolve_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.set_scissor_rect(origin.x, origin.y, size.x, size.y);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Recorder<'a> {
|
||||
pipeline: &'a BasicBrushPipeline,
|
||||
executor: &'a WgpuExecutor,
|
||||
encoder: wgpu::CommandEncoder,
|
||||
region: Region,
|
||||
buffers: Vec<Buffer>,
|
||||
textures: Vec<Texture>,
|
||||
}
|
||||
|
||||
impl<'a> Recorder<'a> {
|
||||
pub(super) fn new(pipeline: &'a BasicBrushPipeline, executor: &'a WgpuExecutor, region: &Region) -> Self {
|
||||
let device = &executor.context().device;
|
||||
let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("basic_brush_encoder") });
|
||||
Self {
|
||||
pipeline,
|
||||
executor,
|
||||
encoder,
|
||||
region: *region,
|
||||
buffers: Vec::new(),
|
||||
textures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn kernel(&self, stroke: &StyledStroke) -> Kernel {
|
||||
self.pipeline.kernels.get(self.executor, stroke, self.region.scale)
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self, target: &wgpu::TextureView) {
|
||||
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_clear_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn clear_field(&mut self, target: &FieldViews) {
|
||||
let attachment = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})
|
||||
};
|
||||
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_clear_pass"),
|
||||
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn scatter(&mut self, target: &FieldViews, edges: &[Edge], kernel: &Kernel) {
|
||||
if edges.is_empty() {
|
||||
return;
|
||||
}
|
||||
let globals = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_scatter_uniform"),
|
||||
contents: bytemuck::bytes_of(&ScatterUniforms {
|
||||
frame_size: [self.region.size.x as f32, self.region.size.y as f32],
|
||||
kernel_scale: kernel.scale,
|
||||
kernel_exponent: kernel.exponent,
|
||||
kernel_section_scale: kernel.section_scale,
|
||||
_pad: 0.,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = kernel.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let bind = self.pipeline.scatter.bind(&self.executor.context().device, &globals, &view);
|
||||
let buffer = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_segment_buffer"),
|
||||
contents: bytemuck::cast_slice(edges),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.pipeline.scatter.encode(&mut self.encoder, target, &bind, &buffer, edges.len() as u32);
|
||||
self.buffers.push(globals);
|
||||
self.buffers.push(buffer);
|
||||
self.textures.push(kernel.texture.clone());
|
||||
}
|
||||
|
||||
pub(super) fn resolve(&mut self, color: Color, crop: &Crop, source: &FieldViews, target: &wgpu::TextureView, scissor: (UVec2, UVec2)) {
|
||||
let globals = resolve_uniform(self.executor, color, crop);
|
||||
let bind = self.pipeline.resolve.bind(&self.executor.context().device, &globals, source);
|
||||
self.pipeline.resolve.encode(&mut self.encoder, target, &bind, scissor);
|
||||
self.buffers.push(globals);
|
||||
}
|
||||
|
||||
pub(super) fn copy(&mut self, from: &Texture, from_origin: UVec2, to: &Texture, to_origin: UVec2) {
|
||||
copy_placed(&mut self.encoder, from, from_origin, to, to_origin);
|
||||
}
|
||||
|
||||
pub(super) fn copy_texture(&mut self, from: &Texture, to: &Texture) {
|
||||
self.encoder.copy_texture_to_texture(from.as_image_copy(), to.as_image_copy(), from.size());
|
||||
}
|
||||
|
||||
pub(super) fn convert(&mut self, source: &wgpu::TextureView, target: &wgpu::TextureView) {
|
||||
self.pipeline.convert.encode(&self.executor.context().device, &mut self.encoder, source, target);
|
||||
}
|
||||
|
||||
pub(super) fn keep(&mut self, texture: Texture) {
|
||||
self.textures.push(texture);
|
||||
}
|
||||
|
||||
pub(super) fn submit(self) {
|
||||
let command = self.encoder.finish();
|
||||
self.executor.context().queue.submit([command]);
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform_entry(binding: u32, visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_uniform(executor: &WgpuExecutor, color: Color, crop: &Crop) -> Buffer {
|
||||
let uniforms = ResolveUniforms {
|
||||
color: [color.r(), color.g(), color.b(), color.a()],
|
||||
density_offset: [crop.origin.x as f32, crop.origin.y as f32],
|
||||
_pad: [0.; 2],
|
||||
};
|
||||
executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_resolve_uniform"),
|
||||
contents: bytemuck::bytes_of(&uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_placed(encoder: &mut wgpu::CommandEncoder, from: &wgpu::Texture, from_origin: UVec2, to: &wgpu::Texture, to_origin: UVec2) {
|
||||
let start = from_origin.max(to_origin);
|
||||
let end = (from_origin + UVec2::new(from.width(), from.height())).min(to_origin + UVec2::new(to.width(), to.height()));
|
||||
if !end.cmpgt(start).all() {
|
||||
return;
|
||||
}
|
||||
let info = |texture, origin: UVec2| wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d { x: origin.x, y: origin.y, z: 0 },
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
};
|
||||
let extent = end - start;
|
||||
encoder.copy_texture_to_texture(
|
||||
info(from, start - from_origin),
|
||||
info(to, start - to_origin),
|
||||
wgpu::Extent3d {
|
||||
width: extent.x,
|
||||
height: extent.y,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal file
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use core_types::transform::Footprint;
|
||||
use glam::{DAffine2, DVec2, UVec2};
|
||||
|
||||
const MAX_RESOLUTION: u32 = 8192;
|
||||
|
||||
const CROP_STEP: u32 = 256;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Region {
|
||||
pub(crate) min: DVec2,
|
||||
pub(crate) scale: f64,
|
||||
pub(crate) size: UVec2,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
pub(crate) fn new(footprint: &Footprint) -> Option<Self> {
|
||||
let margin = DVec2::splat(2. / footprint.scale().max_element());
|
||||
let viewport = footprint.viewport_bounds_in_local_space();
|
||||
let bounds = AxisAlignedBbox {
|
||||
start: viewport.start - margin,
|
||||
end: viewport.end + margin,
|
||||
};
|
||||
if !bounds.size().cmpgt(DVec2::ZERO).all() {
|
||||
return None;
|
||||
}
|
||||
// -2 leaves room for the floor/ceil below to add a texel per side at the cap.
|
||||
let scale = footprint.scale().max_element().min((MAX_RESOLUTION as f64 - 2.) / bounds.size().max_element());
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
let start = (bounds.start * scale).floor();
|
||||
let end = (bounds.end * scale).ceil();
|
||||
let size = (end - start).as_uvec2().max(UVec2::ONE);
|
||||
Some(Self { min: start / scale, scale, size })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Crop {
|
||||
pub(crate) origin: UVec2,
|
||||
pub(crate) size: UVec2,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
pub(crate) fn new(content: AxisAlignedBbox, region: &Region) -> Option<Self> {
|
||||
let start = ((content.start - region.min) * region.scale).floor().max(DVec2::ZERO);
|
||||
let end = ((content.end - region.min) * region.scale).ceil().min(region.size.as_dvec2());
|
||||
if !(end - start).cmpgt(DVec2::ZERO).all() {
|
||||
return None;
|
||||
}
|
||||
let origin = start.as_uvec2() / CROP_STEP * CROP_STEP;
|
||||
let end = ((end.as_uvec2() + UVec2::splat(CROP_STEP - 1)) / CROP_STEP * CROP_STEP).min(region.size);
|
||||
Some(Self { origin, size: end - origin })
|
||||
}
|
||||
|
||||
pub(crate) fn transform(&self, region: &Region) -> DAffine2 {
|
||||
DAffine2::from_translation(region.min + self.origin.as_dvec2() / region.scale) * DAffine2::from_scale(self.size.as_dvec2() / region.scale)
|
||||
}
|
||||
|
||||
pub(crate) fn scissor(&self, region: &Region, bounds: AxisAlignedBbox) -> (UVec2, UVec2) {
|
||||
let clamp = |texels: UVec2| texels.max(self.origin).min(self.origin + self.size) - self.origin;
|
||||
let min = ((bounds.start - region.min) * region.scale).floor().max(DVec2::ZERO).as_uvec2().min(region.size);
|
||||
let max = ((bounds.end - region.min) * region.scale).ceil().max(DVec2::ZERO).as_uvec2().min(region.size);
|
||||
(clamp(min), clamp(max) - clamp(min))
|
||||
}
|
||||
}
|
||||
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal file
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use super::pipeline::{BasicBrushPipeline, COMPOSITE_FORMAT, Field, Recorder};
|
||||
use super::region::{Crop, Region};
|
||||
use super::stroke::{self, StyledStroke, Walk};
|
||||
|
||||
use core_types::CacheHash;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use glam::{DAffine2, UVec2};
|
||||
use raster_types::{Texture, TextureWeakRef};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use wgpu_executor::WgpuExecutor;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct StrokeKey(u64);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct DensityKey(u64);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct PrefixKey(u64);
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct FrameKey {
|
||||
finished: Vec<StrokeKey>,
|
||||
active: StrokeKey,
|
||||
}
|
||||
|
||||
pub(super) struct Frame<'a> {
|
||||
finished: &'a [StyledStroke],
|
||||
active: &'a StyledStroke,
|
||||
}
|
||||
impl<'a> Frame<'a> {
|
||||
pub(super) fn new(strokes: &'a [StyledStroke]) -> Option<Self> {
|
||||
let (active, finished) = strokes.split_last()?;
|
||||
Some(Self { finished, active })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
finished: Finished,
|
||||
pending: Option<Pending>,
|
||||
output: Option<CachedOutput>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Finished {
|
||||
strokes: Vec<Record>,
|
||||
image: Option<Placed<TextureWeakRef>>,
|
||||
}
|
||||
|
||||
struct Record {
|
||||
key: StrokeKey,
|
||||
bounds: AxisAlignedBbox,
|
||||
}
|
||||
|
||||
struct Placed<T> {
|
||||
texture: T,
|
||||
origin: UVec2,
|
||||
}
|
||||
|
||||
struct CachedOutput {
|
||||
key: FrameKey,
|
||||
texture: TextureWeakRef,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
key: PendingKey,
|
||||
walk: Walk,
|
||||
density: TextureWeakRef,
|
||||
stamp: TextureWeakRef,
|
||||
}
|
||||
|
||||
struct LivePending {
|
||||
key: PendingKey,
|
||||
walk: Walk,
|
||||
field: Field,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PendingKey {
|
||||
seed: u64,
|
||||
density: DensityKey,
|
||||
prefix: PrefixKey,
|
||||
}
|
||||
|
||||
impl PendingKey {
|
||||
fn new(stroke: &StyledStroke, consumed: usize) -> Self {
|
||||
Self {
|
||||
seed: stroke.stroke.seed,
|
||||
density: density_key(stroke),
|
||||
prefix: prefix_key(stroke, consumed),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, stroke: &StyledStroke, consumed: usize) -> bool {
|
||||
self.seed == stroke.stroke.seed && self.density == density_key(stroke) && self.prefix == prefix_key(stroke, consumed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn upgrade(self, region: &Region) -> Option<LivePending> {
|
||||
let density = self.density.upgrade()?;
|
||||
let stamp = self.stamp.upgrade()?;
|
||||
if density.width() != region.size.x || density.height() != region.size.y {
|
||||
return None;
|
||||
}
|
||||
Some(LivePending {
|
||||
key: self.key,
|
||||
walk: self.walk,
|
||||
field: Field { density, stamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LivePending {
|
||||
fn matches(&self, stroke: &StyledStroke) -> bool {
|
||||
self.walk.consumed > 0 && self.walk.consumed <= stroke.stroke.len() && self.key.matches(stroke, self.walk.consumed)
|
||||
}
|
||||
|
||||
fn park(self) -> Pending {
|
||||
Pending {
|
||||
key: self.key,
|
||||
walk: self.walk,
|
||||
density: self.field.density.downgrade(),
|
||||
stamp: self.field.stamp.downgrade(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Rendered {
|
||||
pub(super) texture: Texture,
|
||||
pub(super) transform: DAffine2,
|
||||
pub(super) state: State,
|
||||
}
|
||||
|
||||
pub(super) fn render(pipeline: &BasicBrushPipeline, executor: &WgpuExecutor, frame: Frame<'_>, region: Region, mut state: State) -> Option<Rendered> {
|
||||
let keys: Vec<_> = frame.finished.iter().map(stroke_key).collect();
|
||||
let active_key = stroke_key(frame.active);
|
||||
let frame_key = frame_key(&keys, active_key);
|
||||
let prefix = state.finished.strokes.len() <= keys.len() && state.finished.strokes.iter().zip(&keys).all(|(cached, current)| cached.key == *current);
|
||||
let known = if prefix { state.finished.strokes.len() } else { 0 };
|
||||
let mut bounds: Vec<_> = if prefix {
|
||||
state.finished.strokes.iter().map(|record| record.bounds.clone()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
bounds.extend(frame.finished[known..].iter().map(|stroke| stroke::bounds(stroke, region.scale)));
|
||||
let active_bounds = stroke::bounds(frame.active, region.scale);
|
||||
let mut content = None;
|
||||
for bounds in &bounds {
|
||||
stroke::union(&mut content, bounds.clone());
|
||||
}
|
||||
stroke::union(&mut content, active_bounds.clone());
|
||||
let crop = Crop::new(content?, ®ion)?;
|
||||
|
||||
if let Some(texture) = state.output.as_ref().filter(|output| output.key == frame_key).and_then(|output| output.texture.upgrade()) {
|
||||
return Some(Rendered {
|
||||
texture,
|
||||
transform: crop.transform(®ion),
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
let base = state
|
||||
.finished
|
||||
.image
|
||||
.take()
|
||||
.and_then(|placed| {
|
||||
Some(Placed {
|
||||
texture: placed.texture.upgrade()?,
|
||||
origin: placed.origin,
|
||||
})
|
||||
})
|
||||
.filter(|placed| prefix && (placed.origin + UVec2::new(placed.texture.width(), placed.texture.height())).cmple(region.size).all());
|
||||
let covered = if base.is_some() { state.finished.strokes.len() } else { 0 };
|
||||
let missing = &frame.finished[covered..];
|
||||
let pending = state.pending.take().and_then(|pending| pending.upgrade(®ion));
|
||||
let (active_pending, mut finished_pending) = match pending {
|
||||
Some(pending) if pending.matches(frame.active) => (Some(pending), None),
|
||||
pending => (None, pending),
|
||||
};
|
||||
|
||||
let updated = (!missing.is_empty()).then(|| executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT));
|
||||
let composite = executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT);
|
||||
let scratch = Field::request(executor, region.size);
|
||||
let output = executor.request_texture(crop.size);
|
||||
let mut recorder = Recorder::new(pipeline, executor, ®ion);
|
||||
|
||||
if let Some(updated) = &updated {
|
||||
let target = updated.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
recorder.clear(&target);
|
||||
if let Some(base) = &base {
|
||||
recorder.copy(&base.texture, base.origin, updated, crop.origin);
|
||||
}
|
||||
let mut strokes = StrokeRenderer {
|
||||
recorder: &mut recorder,
|
||||
executor,
|
||||
region: ®ion,
|
||||
crop: &crop,
|
||||
scratch: &scratch,
|
||||
};
|
||||
for (index, stroke) in missing.iter().enumerate() {
|
||||
let scissor = crop.scissor(®ion, bounds[covered + index].clone());
|
||||
if !scissor.1.cmpgt(UVec2::ZERO).all() {
|
||||
continue;
|
||||
}
|
||||
let previous = if finished_pending.as_ref().is_some_and(|pending| pending.matches(stroke)) {
|
||||
finished_pending.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
strokes.render(stroke, previous, Tail::Commit, Target { view: &target, scissor });
|
||||
}
|
||||
}
|
||||
|
||||
let composite_view = composite.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
match (&updated, &base) {
|
||||
(Some(updated), _) => recorder.copy_texture(updated, &composite),
|
||||
(None, Some(base)) => {
|
||||
recorder.clear(&composite_view);
|
||||
recorder.copy(&base.texture, base.origin, &composite, crop.origin);
|
||||
}
|
||||
(None, None) => recorder.clear(&composite_view),
|
||||
}
|
||||
let active_scissor = crop.scissor(®ion, active_bounds);
|
||||
let pending = StrokeRenderer {
|
||||
recorder: &mut recorder,
|
||||
executor,
|
||||
region: ®ion,
|
||||
crop: &crop,
|
||||
scratch: &scratch,
|
||||
}
|
||||
.render(
|
||||
frame.active,
|
||||
active_pending,
|
||||
Tail::Preview,
|
||||
Target {
|
||||
view: &composite_view,
|
||||
scissor: active_scissor,
|
||||
},
|
||||
)?;
|
||||
|
||||
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
recorder.convert(&composite_view, &output_view);
|
||||
recorder.submit();
|
||||
|
||||
let image = updated
|
||||
.map(|texture| Placed {
|
||||
texture: texture.downgrade(),
|
||||
origin: crop.origin,
|
||||
})
|
||||
.or_else(|| {
|
||||
base.map(|placed| Placed {
|
||||
texture: placed.texture.downgrade(),
|
||||
origin: placed.origin,
|
||||
})
|
||||
});
|
||||
let state = State {
|
||||
finished: Finished {
|
||||
strokes: keys.into_iter().zip(bounds).map(|(key, bounds)| Record { key, bounds }).collect(),
|
||||
image,
|
||||
},
|
||||
pending: Some(pending.park()),
|
||||
output: Some(CachedOutput {
|
||||
key: frame_key,
|
||||
texture: output.downgrade(),
|
||||
}),
|
||||
};
|
||||
Some(Rendered {
|
||||
texture: output,
|
||||
transform: crop.transform(®ion),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Tail {
|
||||
Commit,
|
||||
Preview,
|
||||
}
|
||||
|
||||
enum Density<'a> {
|
||||
Temporary(&'a Field),
|
||||
Owned(Field),
|
||||
}
|
||||
|
||||
impl Density<'_> {
|
||||
fn field(&self) -> &Field {
|
||||
match self {
|
||||
Self::Temporary(field) => field,
|
||||
Self::Owned(field) => field,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Target<'a> {
|
||||
view: &'a wgpu::TextureView,
|
||||
scissor: (UVec2, UVec2),
|
||||
}
|
||||
|
||||
struct StrokeRenderer<'a, 'gpu> {
|
||||
recorder: &'a mut Recorder<'gpu>,
|
||||
executor: &'gpu WgpuExecutor,
|
||||
region: &'a Region,
|
||||
crop: &'a Crop,
|
||||
scratch: &'a Field,
|
||||
}
|
||||
|
||||
impl StrokeRenderer<'_, '_> {
|
||||
fn render(&mut self, stroke: &StyledStroke, previous: Option<LivePending>, tail: Tail, target: Target<'_>) -> Option<LivePending> {
|
||||
let (mut walk, density) = match previous {
|
||||
Some(pending) => (pending.walk, Density::Owned(pending.field)),
|
||||
None => match tail {
|
||||
Tail::Commit => (Walk::default(), Density::Temporary(self.scratch)),
|
||||
Tail::Preview => (Walk::default(), Density::Owned(Field::request(self.executor, self.region.size))),
|
||||
},
|
||||
};
|
||||
let views = density.field().views();
|
||||
if walk.consumed == 0 {
|
||||
self.recorder.clear_field(&views);
|
||||
}
|
||||
let kernel = self.recorder.kernel(stroke);
|
||||
let mut update = walk.update(stroke, self.region);
|
||||
match tail {
|
||||
Tail::Commit => {
|
||||
update.committed.append(&mut update.tail);
|
||||
self.recorder.scatter(&views, &update.committed, &kernel);
|
||||
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
|
||||
if let Density::Owned(field) = density {
|
||||
self.recorder.keep(field.density);
|
||||
self.recorder.keep(field.stamp);
|
||||
}
|
||||
None
|
||||
}
|
||||
Tail::Preview => {
|
||||
self.recorder.scatter(&views, &update.committed, &kernel);
|
||||
if update.tail.is_empty() {
|
||||
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
|
||||
} else {
|
||||
let field = density.field();
|
||||
self.recorder.copy_texture(&field.density, &self.scratch.density);
|
||||
self.recorder.copy_texture(&field.stamp, &self.scratch.stamp);
|
||||
let scratch_views = self.scratch.views();
|
||||
self.recorder.scatter(&scratch_views, &update.tail, &kernel);
|
||||
self.recorder.resolve(stroke.color, self.crop, &scratch_views, target.view, target.scissor);
|
||||
}
|
||||
let Density::Owned(field) = density else { unreachable!() };
|
||||
Some(LivePending {
|
||||
key: PendingKey::new(stroke, walk.consumed),
|
||||
walk,
|
||||
field,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_key(stroke: &StyledStroke) -> StrokeKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
stroke.stroke.cache_hash(&mut hasher);
|
||||
stroke.color.cache_hash(&mut hasher);
|
||||
stroke.diameter.cache_hash(&mut hasher);
|
||||
stroke.hardness.cache_hash(&mut hasher);
|
||||
stroke.flow.cache_hash(&mut hasher);
|
||||
StrokeKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn density_key(stroke: &StyledStroke) -> DensityKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
(stroke.diameter.max(0.) as f32).to_bits().hash(&mut hasher);
|
||||
(stroke.hardness.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
|
||||
(stroke.flow.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
|
||||
DensityKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn prefix_key(stroke: &StyledStroke, consumed: usize) -> PrefixKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for sample in stroke.stroke.samples().take(consumed) {
|
||||
sample.position.x.to_bits().hash(&mut hasher);
|
||||
sample.position.y.to_bits().hash(&mut hasher);
|
||||
sample.pressure.clamp(0., 1.).to_bits().hash(&mut hasher);
|
||||
}
|
||||
PrefixKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn frame_key(finished: &[StrokeKey], active: StrokeKey) -> FrameKey {
|
||||
FrameKey { finished: finished.to_vec(), active }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brush_types::{Channel, Stroke};
|
||||
use core_types::Color;
|
||||
use glam::DVec2;
|
||||
|
||||
fn stroke() -> StyledStroke {
|
||||
StyledStroke {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 0.8,
|
||||
flow: 1.,
|
||||
stroke: Stroke {
|
||||
position: vec![DVec2::new(1., 2.), DVec2::new(3., 4.), DVec2::new(5., 6.)],
|
||||
pressure: Channel::Samples(vec![0.2, 0.4, 0.6]),
|
||||
seed: 42,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_accepts_an_appended_stroke() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
let mut appended = stroke();
|
||||
appended.stroke.position.push(DVec2::new(7., 8.));
|
||||
let Channel::Samples(pressure) = &mut appended.stroke.pressure else { unreachable!() };
|
||||
pressure.push(0.8);
|
||||
assert!(key.matches(&appended, original.stroke.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_rejects_changed_render_data() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
|
||||
let mut position = stroke();
|
||||
position.stroke.position[0].x += 1.;
|
||||
assert!(!key.matches(&position, original.stroke.len()));
|
||||
|
||||
let mut pressure = stroke();
|
||||
let Channel::Samples(samples) = &mut pressure.stroke.pressure else { unreachable!() };
|
||||
samples[1] += 0.1;
|
||||
assert!(!key.matches(&pressure, original.stroke.len()));
|
||||
|
||||
let mut flow = stroke();
|
||||
flow.flow *= 0.5;
|
||||
assert!(!key.matches(&flow, original.stroke.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_ignores_color() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
let mut recolored = stroke();
|
||||
recolored.color = Color::WHITE;
|
||||
assert!(key.matches(&recolored, original.stroke.len()));
|
||||
}
|
||||
}
|
||||
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal file
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal file
@@ -0,0 +1,43 @@
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
|
||||
let pos = array(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
return vec4f(pos[vertex_index], 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
struct Uniforms {
|
||||
color: vec4<f32>,
|
||||
density_offset: vec2<f32>,
|
||||
_pad: vec2<f32>,
|
||||
};
|
||||
|
||||
override RIDGE_GAIN: f32;
|
||||
override RIDGE_NORM: f32;
|
||||
|
||||
@group(0) @binding(0)
|
||||
var<uniform> uniforms: Uniforms;
|
||||
|
||||
@group(0) @binding(1)
|
||||
var t_density: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(2)
|
||||
var t_stamp: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let texel = vec2<i32>(frag.xy + uniforms.density_offset);
|
||||
let field = max(textureLoad(t_density, texel, 0).r, textureLoad(t_stamp, texel, 0).r);
|
||||
let alpha = clamp((1.0 - exp(-field * RIDGE_GAIN)) * RIDGE_NORM, 0.0, 1.0) * uniforms.color.a;
|
||||
return vec4<f32>(uniforms.color.rgb * alpha, alpha);
|
||||
}
|
||||
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal file
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal file
@@ -0,0 +1,117 @@
|
||||
override CUTOFF_SIGMA: f32;
|
||||
|
||||
override LUT_SIZE: f32;
|
||||
override LUT_V_MAX: f32;
|
||||
override LUT_T_MAX: f32;
|
||||
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
struct Uniforms {
|
||||
frame_size: vec2<f32>,
|
||||
kernel_scale: f32,
|
||||
kernel_exponent: f32,
|
||||
kernel_section_scale: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0)
|
||||
var<uniform> uniforms: Uniforms;
|
||||
|
||||
@group(0) @binding(1)
|
||||
var t_kernel: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(2)
|
||||
var s_kernel: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) @interpolate(flat) a: vec2<f32>,
|
||||
@location(1) @interpolate(flat) b: vec2<f32>,
|
||||
@location(2) @interpolate(flat) sigma: f32,
|
||||
@location(3) @interpolate(flat) weight: f32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vertex_index: u32,
|
||||
@location(0) a: vec2<f32>,
|
||||
@location(1) b: vec2<f32>,
|
||||
@location(2) sigma: f32,
|
||||
@location(3) weight: f32,
|
||||
) -> VertexOutput {
|
||||
let cutoff = CUTOFF_SIGMA * sigma;
|
||||
|
||||
let d = b - a;
|
||||
let len = length(d);
|
||||
var e = vec2f(1.0, 0.0);
|
||||
if (len > 1e-6) {
|
||||
e = d / len;
|
||||
}
|
||||
let n = vec2f(-e.y, e.x);
|
||||
let base = select(a - e * cutoff, b + e * cutoff, (vertex_index & 1u) == 1u);
|
||||
let normal_sign = select(-1.0, 1.0, vertex_index >= 2u);
|
||||
let corner = base + n * (cutoff * normal_sign);
|
||||
|
||||
let ndc = vec2f(corner.x / uniforms.frame_size.x * 2.0 - 1.0, 1.0 - corner.y / uniforms.frame_size.y * 2.0);
|
||||
|
||||
var out: VertexOutput;
|
||||
out.clip_position = vec4f(ndc, 0.0, 1.0);
|
||||
out.a = a;
|
||||
out.b = b;
|
||||
out.sigma = sigma;
|
||||
out.weight = weight;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
fn sweep(v: f32, t: f32) -> f32 {
|
||||
let texel = (LUT_SIZE - 1.0) / LUT_SIZE;
|
||||
let uv = vec2f(
|
||||
((t + LUT_T_MAX) / (2.0 * LUT_T_MAX)) * texel + 0.5 / LUT_SIZE,
|
||||
(v / LUT_V_MAX) * texel + 0.5 / LUT_SIZE,
|
||||
);
|
||||
return textureSampleLevel(t_kernel, s_kernel, uv, 0.0).r;
|
||||
}
|
||||
|
||||
fn section(r2: f32) -> f32 {
|
||||
// Max avoids pow undefined log at zero.
|
||||
return exp(-pow(max(r2 * 0.5, 1e-20), uniforms.kernel_exponent));
|
||||
}
|
||||
|
||||
struct FragmentOutput {
|
||||
@location(0) density: f32,
|
||||
@location(1) stamp: f32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> FragmentOutput {
|
||||
let p = in.clip_position.xy;
|
||||
let inv_section = uniforms.kernel_section_scale / in.sigma;
|
||||
let d = in.b - in.a;
|
||||
let len = length(d);
|
||||
if (len < 1e-6) {
|
||||
let dab = in.weight * section(dot(p - in.a, p - in.a) * inv_section * inv_section);
|
||||
return FragmentOutput(dab, dab);
|
||||
}
|
||||
let e = d / len;
|
||||
let rel = p - in.a;
|
||||
let along = dot(rel, e);
|
||||
let perp2 = max(dot(rel, rel) - along * along, 0.0);
|
||||
|
||||
let cutoff = CUTOFF_SIGMA * in.sigma;
|
||||
if (perp2 > cutoff * cutoff || along < -cutoff || along > len + cutoff) {
|
||||
return FragmentOutput(0.0, 0.0);
|
||||
}
|
||||
let inv_sp = uniforms.kernel_scale / in.sigma;
|
||||
let v = sqrt(perp2) * inv_sp;
|
||||
let ridge = sweep(v, along * inv_sp) - sweep(v, (along - len) * inv_sp);
|
||||
|
||||
let overhang = max(max(-along, along - len), 0.0);
|
||||
let stamp = in.weight * section((perp2 + overhang * overhang) * inv_section * inv_section);
|
||||
|
||||
return FragmentOutput(in.weight * max(ridge, 0.0), stamp);
|
||||
}
|
||||
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal file
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use super::consts::{RIDGE_GAIN, SIGMA_CUTOFF, SIGMA_PER_DIAMETER};
|
||||
use brush_types::{Sample, Stroke};
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use core_types::Color;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use glam::DVec2;
|
||||
|
||||
const MIN_SIGMA: f32 = f32::EPSILON;
|
||||
const MAX_EDGE_SHIFT: f32 = 0.25;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||
pub(super) struct Edge {
|
||||
a: [f32; 2],
|
||||
b: [f32; 2],
|
||||
sigma: f32,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
pub(super) struct StyledStroke {
|
||||
pub(super) color: Color,
|
||||
pub(super) diameter: f64,
|
||||
pub(super) hardness: f64,
|
||||
pub(super) flow: f64,
|
||||
pub(super) stroke: Stroke,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Dab {
|
||||
position: DVec2,
|
||||
sigma: f32,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
fn dab(sample: &Sample, stroke: &StyledStroke) -> Dab {
|
||||
let pressure = sample.pressure.clamp(0., 1.);
|
||||
let flow = stroke.flow.clamp(0., 1.) as f32;
|
||||
Dab {
|
||||
position: sample.position,
|
||||
sigma: (stroke.diameter.max(0.) * SIGMA_PER_DIAMETER) as f32 * pressure,
|
||||
weight: -(1. - flow * (1. - (-RIDGE_GAIN).exp())).ln() / RIDGE_GAIN,
|
||||
}
|
||||
}
|
||||
|
||||
fn dab_pad(dab: Dab, scale: f64) -> AxisAlignedBbox {
|
||||
let sigma = (dab.sigma as f64).max(MIN_SIGMA as f64 / scale);
|
||||
let pad = DVec2::splat(SIGMA_CUTOFF as f64 * sigma + 1f64.max(1. / scale));
|
||||
AxisAlignedBbox {
|
||||
start: dab.position - pad,
|
||||
end: dab.position + pad,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn union(bounds: &mut Option<AxisAlignedBbox>, other: AxisAlignedBbox) {
|
||||
*bounds = Some(match bounds.take() {
|
||||
Some(existing) => existing.union(&other),
|
||||
None => other,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn bounds(stroke: &StyledStroke, scale: f64) -> AxisAlignedBbox {
|
||||
let mut bounds = None;
|
||||
for sample in stroke.stroke.samples() {
|
||||
union(&mut bounds, dab_pad(dab(&sample, stroke), scale));
|
||||
}
|
||||
bounds.unwrap_or(AxisAlignedBbox::ZERO)
|
||||
}
|
||||
|
||||
pub(super) struct Update {
|
||||
pub(super) committed: Vec<Edge>,
|
||||
pub(super) tail: Vec<Edge>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Walk {
|
||||
sigma_min: f32,
|
||||
kept_last: Option<Dab>,
|
||||
kept: usize,
|
||||
pub(super) consumed: usize,
|
||||
}
|
||||
|
||||
impl Default for Walk {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sigma_min: f32::MAX,
|
||||
kept_last: None,
|
||||
kept: 0,
|
||||
consumed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn advance(&mut self, stroke: &StyledStroke, scale: f64) -> Vec<Dab> {
|
||||
let mut kept = Vec::new();
|
||||
for index in self.consumed..stroke.stroke.len() {
|
||||
let sample = stroke.stroke.sample(index);
|
||||
let dab = dab(&sample, stroke);
|
||||
self.sigma_min = self.sigma_min.min(dab.sigma);
|
||||
let min_step = (self.sigma_min as f64 * 0.5).max(0.5 / scale);
|
||||
if self.kept_last.is_none_or(|last| last.position.distance(dab.position) >= min_step) {
|
||||
kept.push(dab);
|
||||
self.kept_last = Some(dab);
|
||||
self.kept += 1;
|
||||
}
|
||||
}
|
||||
self.consumed = stroke.stroke.len();
|
||||
kept
|
||||
}
|
||||
|
||||
fn tail(&self, stroke: &StyledStroke) -> Option<(Dab, Dab)> {
|
||||
let kept_last = self.kept_last?;
|
||||
let dab = dab(&stroke.stroke.sample(stroke.stroke.len() - 1), stroke);
|
||||
if dab.position == kept_last.position {
|
||||
return (self.kept == 1).then_some((kept_last, kept_last));
|
||||
}
|
||||
Some((kept_last, dab))
|
||||
}
|
||||
|
||||
pub(super) fn update(&mut self, stroke: &StyledStroke, region: &super::region::Region) -> Update {
|
||||
let previous = self.kept_last;
|
||||
let kept = self.advance(stroke, region.scale);
|
||||
let tail = self.tail(stroke);
|
||||
Update {
|
||||
committed: edges(region, previous, &kept, None),
|
||||
tail: edges(region, None, &[], tail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn texel(region: &super::region::Region, p: DVec2) -> [f32; 2] {
|
||||
[((p.x - region.min.x) * region.scale) as f32, ((p.y - region.min.y) * region.scale) as f32]
|
||||
}
|
||||
|
||||
fn edge(region: &super::region::Region, a: Dab, b: Dab) -> Edge {
|
||||
Edge {
|
||||
a: texel(region, a.position),
|
||||
b: texel(region, b.position),
|
||||
sigma: ((a.sigma + b.sigma) / 2. * region.scale as f32).max(MIN_SIGMA),
|
||||
weight: (a.weight + b.weight) / 2.,
|
||||
}
|
||||
}
|
||||
|
||||
fn mix(a: Dab, b: Dab, t: f32) -> Dab {
|
||||
Dab {
|
||||
position: a.position.lerp(b.position, t as f64),
|
||||
sigma: a.sigma + (b.sigma - a.sigma) * t,
|
||||
weight: a.weight + (b.weight - a.weight) * t,
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_edges(edges: &mut Vec<Edge>, region: &super::region::Region, a: Dab, b: Dab) {
|
||||
let scale = region.scale as f32;
|
||||
let gradient = (a.sigma.min(b.sigma) * scale).max(1.);
|
||||
let shift = (b.sigma - a.sigma).abs() * scale * SIGMA_CUTOFF;
|
||||
let pieces = (shift / (MAX_EDGE_SHIFT * gradient)).ceil().clamp(1., 64.) as usize;
|
||||
let mut previous = a;
|
||||
for piece in 1..=pieces {
|
||||
let next = if piece == pieces { b } else { mix(a, b, piece as f32 / pieces as f32) };
|
||||
edges.push(edge(region, previous, next));
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn edges(region: &super::region::Region, prev: Option<Dab>, kept: &[Dab], tail: Option<(Dab, Dab)>) -> Vec<Edge> {
|
||||
let mut edges = Vec::with_capacity(kept.len() + 1);
|
||||
let mut last = prev;
|
||||
for &dab in kept {
|
||||
if let Some(previous) = last {
|
||||
segment_edges(&mut edges, region, previous, dab);
|
||||
}
|
||||
last = Some(dab);
|
||||
}
|
||||
if let Some((a, b)) = tail {
|
||||
segment_edges(&mut edges, region, a, b);
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::{DVec2, UVec2};
|
||||
|
||||
fn stroke(points: &[[f64; 2]]) -> StyledStroke {
|
||||
StyledStroke {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 0.8,
|
||||
flow: 1.,
|
||||
stroke: Stroke {
|
||||
position: points.iter().copied().map(DVec2::from).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> super::super::region::Region {
|
||||
super::super::region::Region {
|
||||
min: DVec2::ZERO,
|
||||
scale: 2.,
|
||||
size: UVec2::splat(512),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_walk_matches_whole_stroke() {
|
||||
let partial = stroke(&[[10., 10.], [15., 12.], [20., 15.]]);
|
||||
let complete = stroke(&[[10., 10.], [15., 12.], [20., 15.], [31., 19.], [45., 24.]]);
|
||||
let region = region();
|
||||
|
||||
let mut chunked = Walk::default();
|
||||
let first = chunked.update(&partial, ®ion);
|
||||
let second = chunked.update(&complete, ®ion);
|
||||
let mut committed = first.committed;
|
||||
committed.extend(second.committed);
|
||||
|
||||
let whole = Walk::default().update(&complete, ®ion);
|
||||
assert_eq!(committed, whole.committed);
|
||||
assert_eq!(second.tail, whole.tail);
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
use crate::brush_cache::BrushCache;
|
||||
use crate::brush_stroke::{BrushStyle, BrushTrace};
|
||||
use core_types::ATTR_TRANSFORM;
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||||
use core_types::color::{Alpha, Color, Pixel, Sample};
|
||||
use core_types::generic::FnNode;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::bbox::{AxisAlignedBbox, Bbox};
|
||||
use core_types::registry::FutureWrapperNode;
|
||||
use core_types::transform::Transform;
|
||||
use core_types::value::ClonedNode;
|
||||
use core_types::{Ctx, Node};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use raster_nodes::blending_nodes::blend_colors;
|
||||
use raster_nodes::std_nodes::{empty_image, extend_image_to_bounds};
|
||||
use raster_types::BitmapMut;
|
||||
use raster_types::Image;
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BrushStampGenerator<P: Pixel + Alpha> {
|
||||
color: P,
|
||||
feather_exponent: f32,
|
||||
transform: DAffine2,
|
||||
}
|
||||
|
||||
impl<P: Pixel + Alpha> Transform for BrushStampGenerator<P> {
|
||||
fn transform(&self) -> DAffine2 {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Pixel + Alpha> Sample for BrushStampGenerator<P> {
|
||||
type Pixel = P;
|
||||
|
||||
#[inline]
|
||||
fn sample(&self, position: DVec2, area: DVec2) -> Option<P> {
|
||||
let position = self.transform.inverse().transform_point2(position);
|
||||
let area = self.transform.inverse().transform_vector2(area);
|
||||
let aa_blur_radius = area.length() as f32 * 2.;
|
||||
let center = DVec2::splat(0.5);
|
||||
|
||||
let distance = (position + area / 2. - center).length() as f32 * 2.;
|
||||
|
||||
let edge_opacity = 1. - (1. - aa_blur_radius).powf(self.feather_exponent);
|
||||
let result = if distance < 1. - aa_blur_radius {
|
||||
1. - distance.powf(self.feather_exponent)
|
||||
} else if distance < 1. {
|
||||
// TODO: Replace this with a proper analytical AA implementation
|
||||
edge_opacity * ((1. - distance) / aa_blur_radius)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
use core_types::color::Channel;
|
||||
Some(self.color.multiplied_alpha(P::AlphaChannel::from_linear(result)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls the brush shape with diameter and hardness, plus color and opacity (via flow).
|
||||
/// The feather exponent is calculated from hardness to determine edge softness.
|
||||
/// Used internally to create the brush texture before stamping it repeatedly along a stroke path.
|
||||
#[node_macro::node(category(""), skip_impl)]
|
||||
fn brush_stamp_generator(#[unit(" px")] diameter: f64, color: Color, hardness: f64, flow: f64) -> BrushStampGenerator<Color> {
|
||||
// Diameter
|
||||
let radius = diameter / 2.;
|
||||
|
||||
// Hardness
|
||||
let hardness = hardness / 100.;
|
||||
let feather_exponent = 1. / (1. - hardness) as f32;
|
||||
|
||||
// Flow
|
||||
let flow = flow / 100.;
|
||||
|
||||
// Color
|
||||
let color = color.apply_opacity(flow as f32);
|
||||
|
||||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(diameter), 0., -DVec2::splat(radius));
|
||||
BrushStampGenerator { color, feather_exponent, transform }
|
||||
}
|
||||
|
||||
/// Used to efficiently paint brush strokes. Applies the same texture repeatedly at different positions with proper blending and boundary handling.
|
||||
#[node_macro::node(category(""), skip_impl)]
|
||||
fn blit<BlendFn>(target: List<Raster<CPU>>, texture: Raster<CPU>, positions: Vec<DVec2>, blend_mode: BlendFn) -> List<Raster<CPU>>
|
||||
where
|
||||
BlendFn: for<'any_input> Node<'any_input, (Color, Color), Output = Color>,
|
||||
{
|
||||
let mut target = target;
|
||||
if positions.is_empty() {
|
||||
return target;
|
||||
}
|
||||
|
||||
let (elements, transforms) = target.element_and_attribute_slices_mut::<DAffine2>(ATTR_TRANSFORM);
|
||||
for (element, transform_attribute) in elements.iter_mut().zip(transforms.iter()) {
|
||||
let target_width = element.width;
|
||||
let target_height = element.height;
|
||||
let target_size = DVec2::new(target_width as f64, target_height as f64);
|
||||
|
||||
let texture_size = DVec2::new(texture.width as f64, texture.height as f64);
|
||||
|
||||
let document_to_target = DAffine2::from_translation(-texture_size / 2.) * DAffine2::from_scale(target_size) * transform_attribute.inverse();
|
||||
|
||||
for position in &positions {
|
||||
let start = document_to_target.transform_point2(*position).round();
|
||||
let stop = start + texture_size;
|
||||
|
||||
// Half-open integer ranges [start, stop).
|
||||
let clamp_start = start.clamp(DVec2::ZERO, target_size).as_uvec2();
|
||||
let clamp_stop = stop.clamp(DVec2::ZERO, target_size).as_uvec2();
|
||||
|
||||
let blit_area_offset = (clamp_start.as_dvec2() - start).as_uvec2().min(texture_size.as_uvec2());
|
||||
let blit_area_dimensions = (clamp_stop - clamp_start).min(texture_size.as_uvec2() - blit_area_offset);
|
||||
|
||||
// Tight blitting loop. Eagerly assert bounds to hopefully eliminate bounds check inside loop.
|
||||
let texture_index = |x: u32, y: u32| -> usize { (y as usize * texture.width as usize) + (x as usize) };
|
||||
let target_index = |x: u32, y: u32| -> usize { (y as usize * target_width as usize) + (x as usize) };
|
||||
|
||||
let max_y = (blit_area_offset.y + blit_area_dimensions.y).saturating_sub(1);
|
||||
let max_x = (blit_area_offset.x + blit_area_dimensions.x).saturating_sub(1);
|
||||
assert!(texture_index(max_x, max_y) < texture.data.len());
|
||||
assert!(target_index(max_x, max_y) < element.data.len());
|
||||
|
||||
for y in blit_area_offset.y..blit_area_offset.y + blit_area_dimensions.y {
|
||||
for x in blit_area_offset.x..blit_area_offset.x + blit_area_dimensions.x {
|
||||
let src_pixel = texture.data[texture_index(x, y)];
|
||||
let dst_pixel = &mut element.data_mut().data[target_index(x + clamp_start.x, y + clamp_start.y)];
|
||||
*dst_pixel = blend_mode.eval((src_pixel, *dst_pixel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target
|
||||
}
|
||||
|
||||
pub async fn create_brush_texture(brush_style: &BrushStyle) -> Raster<CPU> {
|
||||
let stamp = brush_stamp_generator(brush_style.diameter, brush_style.color, brush_style.hardness, brush_style.flow);
|
||||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(brush_style.diameter), 0., -DVec2::splat(brush_style.diameter / 2.));
|
||||
let blank_texture = empty_image((), Item::new_from_element(transform), Item::new_from_element(Color::TRANSPARENT));
|
||||
let image = blend_stamp_closure(stamp, blank_texture, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
|
||||
image.into_element()
|
||||
}
|
||||
|
||||
pub fn blend_with_mode(background: Item<Raster<CPU>>, foreground: Item<Raster<CPU>>, blend_mode: BlendMode, opacity: f64) -> Item<Raster<CPU>> {
|
||||
let opacity = opacity as f32 / 100.;
|
||||
match std::hint::black_box(blend_mode) {
|
||||
// Normal group
|
||||
BlendMode::Normal => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Normal, opacity)),
|
||||
// Darken group
|
||||
BlendMode::Darken => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Darken, opacity)),
|
||||
BlendMode::Multiply => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Multiply, opacity)),
|
||||
BlendMode::ColorBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorBurn, opacity)),
|
||||
BlendMode::LinearBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearBurn, opacity)),
|
||||
BlendMode::DarkerColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::DarkerColor, opacity)),
|
||||
// Lighten group
|
||||
BlendMode::Lighten => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Lighten, opacity)),
|
||||
BlendMode::Screen => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Screen, opacity)),
|
||||
BlendMode::ColorDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorDodge, opacity)),
|
||||
BlendMode::LinearDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearDodge, opacity)),
|
||||
BlendMode::LighterColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LighterColor, opacity)),
|
||||
// Contrast group
|
||||
BlendMode::Overlay => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Overlay, opacity)),
|
||||
BlendMode::SoftLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::SoftLight, opacity)),
|
||||
BlendMode::HardLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardLight, opacity)),
|
||||
BlendMode::VividLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::VividLight, opacity)),
|
||||
BlendMode::LinearLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearLight, opacity)),
|
||||
BlendMode::PinLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::PinLight, opacity)),
|
||||
BlendMode::HardMix => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardMix, opacity)),
|
||||
// Inversion group
|
||||
BlendMode::Difference => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Difference, opacity)),
|
||||
BlendMode::Exclusion => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Exclusion, opacity)),
|
||||
BlendMode::Subtract => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Subtract, opacity)),
|
||||
BlendMode::Divide => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Divide, opacity)),
|
||||
// Component group
|
||||
BlendMode::Hue => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Hue, opacity)),
|
||||
BlendMode::Saturation => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Saturation, opacity)),
|
||||
BlendMode::Color => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Color, opacity)),
|
||||
BlendMode::Luminosity => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Luminosity, opacity)),
|
||||
// Other utility blend modes (hidden from the normal list)
|
||||
BlendMode::Erase => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Erase, opacity)),
|
||||
BlendMode::Restore => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Restore, opacity)),
|
||||
BlendMode::MultiplyAlpha => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, opacity)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the brush strokes painted with the Brush tool as a raster image.
|
||||
/// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed.
|
||||
#[node_macro::node(category("Raster"))]
|
||||
async fn brush(
|
||||
_: impl Ctx,
|
||||
/// Optional raster content that may be drawn onto.
|
||||
background: Item<Raster<CPU>>,
|
||||
/// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles.
|
||||
trace: Item<BrushTrace>,
|
||||
/// Internal cache data used to accelerate rendering of the brush content.
|
||||
#[data]
|
||||
cache: BrushCache,
|
||||
) -> Item<Raster<CPU>> {
|
||||
let trace = trace.into_element().0;
|
||||
|
||||
let list_item = background;
|
||||
let mut result_item = list_item.clone();
|
||||
|
||||
let bounds = List::new_from_item(list_item.clone()).bounding_box(DAffine2::IDENTITY, false);
|
||||
let [start, end] = if let RenderBoundingBox::Rectangle(rect) = bounds { rect } else { [DVec2::ZERO, DVec2::ZERO] };
|
||||
let background_bbox = AxisAlignedBbox { start, end };
|
||||
let stroke_bbox = trace.iter_element_values().map(|s| s.bounding_box()).reduce(|a, b| a.union(&b)).unwrap_or(AxisAlignedBbox::ZERO);
|
||||
let bbox = if background_bbox.size().length() < 0.1 {
|
||||
stroke_bbox
|
||||
} else {
|
||||
stroke_bbox.union(&background_bbox)
|
||||
};
|
||||
let background_bounds = bbox.to_transform();
|
||||
|
||||
let mut draw_strokes: Vec<_> = trace
|
||||
.iter_element_values()
|
||||
.filter(|&s| !matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut brush_plan = cache.compute_brush_plan(list_item, &draw_strokes);
|
||||
|
||||
let mut actual_image = extend_image_to_bounds((), brush_plan.background, Item::new_from_element(background_bounds));
|
||||
|
||||
let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1);
|
||||
for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() {
|
||||
// Create brush texture.
|
||||
// TODO: apply rotation from layer to stamp for non-rotationally-symmetric brushes.
|
||||
let mut brush_texture = cache.get_cached_brush(&stroke.style);
|
||||
if brush_texture.is_none() {
|
||||
let tex = create_brush_texture(&stroke.style).await;
|
||||
cache.store_brush(stroke.style.clone(), tex.clone());
|
||||
brush_texture = Some(tex);
|
||||
}
|
||||
let brush_texture = brush_texture.unwrap();
|
||||
|
||||
// Compute transformation from stroke texture space into layer space, and create the stroke texture.
|
||||
let skip = if idx == 0 { brush_plan.first_stroke_point_skip } else { 0 };
|
||||
let positions: Vec<_> = stroke.compute_blit_points().into_iter().skip(skip).collect();
|
||||
let stroke_texture = if idx == 0 && positions.is_empty() {
|
||||
core::mem::take(&mut brush_plan.first_stroke_texture)
|
||||
} else {
|
||||
let mut bbox = stroke.bounding_box();
|
||||
bbox.start = bbox.start.floor();
|
||||
bbox.end = bbox.end.floor();
|
||||
let stroke_size = bbox.size() + DVec2::splat(stroke.style.diameter);
|
||||
// For numerical stability we want to place the first blit point at a stable, integer offset in layer space.
|
||||
let snap_offset = positions[0].floor() - positions[0];
|
||||
let stroke_origin_in_layer = bbox.start - snap_offset - DVec2::splat(stroke.style.diameter / 2.);
|
||||
let stroke_to_layer = DAffine2::from_translation(stroke_origin_in_layer) * DAffine2::from_scale(stroke_size);
|
||||
|
||||
let normal_blend = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
let blit_node = BlitNode::new(
|
||||
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
|
||||
FutureWrapperNode::new(ClonedNode::new(positions)),
|
||||
FutureWrapperNode::new(ClonedNode::new(normal_blend)),
|
||||
);
|
||||
let blit_target = if idx == 0 {
|
||||
let target = core::mem::take(&mut brush_plan.first_stroke_texture);
|
||||
List::new_from_item(extend_image_to_bounds((), target, Item::new_from_element(stroke_to_layer)))
|
||||
} else {
|
||||
List::new_from_item(empty_image((), Item::new_from_element(stroke_to_layer), Item::new_from_element(Color::TRANSPARENT)))
|
||||
};
|
||||
|
||||
let list = blit_node.eval(blit_target).await;
|
||||
assert_eq!(list.len(), 1);
|
||||
list.into_iter().next().unwrap_or_default()
|
||||
};
|
||||
|
||||
// Cache image before doing final blend, and store final stroke texture.
|
||||
if idx == final_stroke_idx {
|
||||
cache.cache_results(core::mem::take(&mut draw_strokes), actual_image.clone(), stroke_texture.clone());
|
||||
}
|
||||
|
||||
// TODO: Is this the correct way to do opacity in blending?
|
||||
actual_image = blend_with_mode(actual_image, stroke_texture, stroke.style.blend_mode, (stroke.style.color.a() * 100.) as f64);
|
||||
}
|
||||
|
||||
let has_erase_or_restore_strokes = trace.iter_element_values().any(|s| matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore));
|
||||
if has_erase_or_restore_strokes {
|
||||
let opaque_image = Image::new(bbox.size().x as u32, bbox.size().y as u32, Color::WHITE);
|
||||
let mut erase_restore_mask = Item::new_from_element(Raster::new_cpu(opaque_image)).with_attribute(ATTR_TRANSFORM, background_bounds);
|
||||
|
||||
for stroke in trace.into_iter().map(|row| row.into_element()) {
|
||||
let mut brush_texture = cache.get_cached_brush(&stroke.style);
|
||||
if brush_texture.is_none() {
|
||||
let tex = create_brush_texture(&stroke.style).await;
|
||||
cache.store_brush(stroke.style.clone(), tex.clone());
|
||||
brush_texture = Some(tex);
|
||||
}
|
||||
let brush_texture = brush_texture.unwrap();
|
||||
let positions: Vec<_> = stroke.compute_blit_points().into_iter().collect();
|
||||
|
||||
// For mask composition: Erase subtracts alpha, Restore adds alpha, and Draw acts like Restore to allow repainting erased areas.
|
||||
let mask_blend_mode = match stroke.style.blend_mode {
|
||||
BlendMode::Erase => BlendMode::Erase,
|
||||
BlendMode::Restore => BlendMode::Restore,
|
||||
_ => BlendMode::Restore,
|
||||
};
|
||||
|
||||
let blend_params = FnNode::new(move |(a, b)| blend_colors(a, b, mask_blend_mode, 1.));
|
||||
let blit_node = BlitNode::new(
|
||||
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
|
||||
FutureWrapperNode::new(ClonedNode::new(positions)),
|
||||
FutureWrapperNode::new(ClonedNode::new(blend_params)),
|
||||
);
|
||||
erase_restore_mask = blit_node.eval(List::new_from_item(erase_restore_mask)).await.into_iter().next().unwrap_or_default();
|
||||
}
|
||||
|
||||
let blend_params = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.));
|
||||
actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_params.eval((a, b)));
|
||||
}
|
||||
|
||||
// The paint operation changes only the raster and its bounds, so set just the resulting transform; blending, opacity,
|
||||
// clipping, and layer-path attributes carry through from the input `background` rather than being invented here.
|
||||
let transform: DAffine2 = actual_image.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
|
||||
*result_item.element_mut() = actual_image.into_element();
|
||||
result_item.set_attribute(ATTR_TRANSFORM, transform);
|
||||
|
||||
result_item
|
||||
}
|
||||
|
||||
pub fn blend_image_closure(foreground: Item<Raster<CPU>>, mut background: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<Raster<CPU>> {
|
||||
let foreground_size = DVec2::new(foreground.element().width as f64, foreground.element().height as f64);
|
||||
let background_size = DVec2::new(background.element().width as f64, background.element().height as f64);
|
||||
|
||||
// Transforms a point from the background image to the foreground image
|
||||
let foreground_transform: DAffine2 = foreground.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let background_to_foreground = DAffine2::from_scale(foreground_size) * foreground_transform.inverse() * background_transform * DAffine2::from_scale(1. / background_size);
|
||||
|
||||
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
|
||||
let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground_transform).to_axis_aligned_bbox();
|
||||
|
||||
// Clamp the foreground image to the background image
|
||||
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
|
||||
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
|
||||
|
||||
for y in start.y..end.y {
|
||||
for x in start.x..end.x {
|
||||
let background_point = DVec2::new(x as f64, y as f64);
|
||||
let foreground_point = background_to_foreground.transform_point2(background_point);
|
||||
|
||||
let source_pixel = foreground.element().sample(foreground_point);
|
||||
let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
*destination_pixel = map_fn(source_pixel, *destination_pixel);
|
||||
}
|
||||
}
|
||||
|
||||
background
|
||||
}
|
||||
|
||||
pub fn blend_stamp_closure(foreground: BrushStampGenerator<Color>, mut background: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<Raster<CPU>> {
|
||||
let background_size = DVec2::new(background.element().width as f64, background.element().height as f64);
|
||||
|
||||
// Transforms a point from the background image to the foreground image
|
||||
let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let background_to_foreground = background_transform * DAffine2::from_scale(1. / background_size);
|
||||
|
||||
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
|
||||
let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground.transform()).to_axis_aligned_bbox();
|
||||
|
||||
// Clamp the foreground image to the background image
|
||||
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
|
||||
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
|
||||
|
||||
let area = background_to_foreground.transform_point2(DVec2::new(1., 1.)) - background_to_foreground.transform_point2(DVec2::ZERO);
|
||||
for y in start.y..end.y {
|
||||
for x in start.x..end.x {
|
||||
let background_point = DVec2::new(x as f64, y as f64);
|
||||
let foreground_point = background_to_foreground.transform_point2(background_point);
|
||||
|
||||
let Some(source_pixel) = foreground.sample(foreground_point, area) else { continue };
|
||||
let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
*destination_pixel = map_fn(source_pixel, *destination_pixel);
|
||||
}
|
||||
}
|
||||
|
||||
background
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
use core_types::transform::Transform;
|
||||
use glam::DAffine2;
|
||||
|
||||
#[test]
|
||||
fn test_brush_texture() {
|
||||
let size = 20.;
|
||||
let image = brush_stamp_generator(size, Color::BLACK, 100., 100.);
|
||||
assert_eq!(image.transform(), DAffine2::from_scale_angle_translation(DVec2::splat(size.ceil()), 0., -DVec2::splat(size / 2.)));
|
||||
// center pixel should be BLACK
|
||||
assert_eq!(image.sample(DVec2::splat(0.), DVec2::ONE), Some(Color::BLACK));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brush_output_size() {
|
||||
let image = brush(
|
||||
(),
|
||||
&BrushCache::default(),
|
||||
Item::new_from_element(Raster::new_cpu(Image::<Color>::default())),
|
||||
Item::new_from_element(BrushTrace::from(vec![BrushStroke {
|
||||
trace: vec![crate::brush_stroke::BrushInputSample { position: DVec2::ZERO }],
|
||||
style: BrushStyle {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 20.,
|
||||
flow: 20.,
|
||||
spacing: 20.,
|
||||
blend_mode: BlendMode::Normal,
|
||||
},
|
||||
}])),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(image.element().width, 20);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
use crate::brush_stroke::BrushStyle;
|
||||
use core_types::ATTR_TRANSFORM;
|
||||
use core_types::graphene_hash::CacheHashWrapper;
|
||||
use core_types::list::Item;
|
||||
use raster_types::CPU;
|
||||
use raster_types::Raster;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct BrushCacheImpl {
|
||||
// The full previous input that was cached.
|
||||
prev_input: Vec<BrushStroke>,
|
||||
|
||||
// The strokes that have been fully processed and blended into the background.
|
||||
background: Item<Raster<CPU>>,
|
||||
blended_image: Item<Raster<CPU>>,
|
||||
last_stroke_texture: Item<Raster<CPU>>,
|
||||
|
||||
// A cache for brush textures.
|
||||
brush_texture_cache: HashMap<CacheHashWrapper<BrushStyle>, Raster<CPU>>,
|
||||
}
|
||||
|
||||
impl BrushCacheImpl {
|
||||
fn compute_brush_plan(&mut self, mut background: Item<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
|
||||
// Do background invalidation.
|
||||
if background != self.background {
|
||||
self.background = background.clone();
|
||||
return BrushPlan {
|
||||
strokes: input.to_vec(),
|
||||
background,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Do blended_image invalidation.
|
||||
let blended_strokes = &self.prev_input[..self.prev_input.len().saturating_sub(1)];
|
||||
let num_blended_strokes = blended_strokes.len();
|
||||
if input.get(..num_blended_strokes) != Some(blended_strokes) {
|
||||
return BrushPlan {
|
||||
strokes: input.to_vec(),
|
||||
background,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Take our previous blended image (and invalidate the cache).
|
||||
// Since we're about to replace our cache anyway, this saves a clone.
|
||||
background = std::mem::take(&mut self.blended_image);
|
||||
|
||||
// Check if the first non-blended stroke is an extension of the last one.
|
||||
// Transform is set to ZERO (not the default IDENTITY) as a sentinel to mark this item as uninitialized.
|
||||
let mut first_stroke_texture = Item::new_from_element(Raster::<CPU>::default()).with_attribute(ATTR_TRANSFORM, glam::DAffine2::ZERO);
|
||||
let mut first_stroke_point_skip = 0;
|
||||
let strokes = input[num_blended_strokes..].to_vec();
|
||||
if !strokes.is_empty() && self.prev_input.len() > num_blended_strokes {
|
||||
let last_stroke = &self.prev_input[num_blended_strokes];
|
||||
let same_style = strokes[0].style == last_stroke.style;
|
||||
let prev_points = last_stroke.compute_blit_points();
|
||||
let new_points = strokes[0].compute_blit_points();
|
||||
let is_point_prefix = new_points.get(..prev_points.len()) == Some(&prev_points);
|
||||
if same_style && is_point_prefix {
|
||||
first_stroke_texture = std::mem::take(&mut self.last_stroke_texture);
|
||||
first_stroke_point_skip = prev_points.len();
|
||||
}
|
||||
}
|
||||
|
||||
self.prev_input = Vec::new();
|
||||
BrushPlan {
|
||||
strokes,
|
||||
background,
|
||||
first_stroke_texture,
|
||||
first_stroke_point_skip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_results(&mut self, input: Vec<BrushStroke>, blended_image: Item<Raster<CPU>>, last_stroke_texture: Item<Raster<CPU>>) {
|
||||
self.prev_input = input;
|
||||
self.blended_image = blended_image;
|
||||
self.last_stroke_texture = last_stroke_texture;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrushPlan {
|
||||
pub strokes: Vec<BrushStroke>,
|
||||
pub background: Item<Raster<CPU>>,
|
||||
pub first_stroke_texture: Item<Raster<CPU>>,
|
||||
pub first_stroke_point_skip: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BrushCache(Arc<Mutex<BrushCacheImpl>>);
|
||||
|
||||
impl BrushCache {
|
||||
pub fn compute_brush_plan(&self, background: Item<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.compute_brush_plan(background, input)
|
||||
}
|
||||
|
||||
pub fn cache_results(&self, input: Vec<BrushStroke>, blended_image: Item<Raster<CPU>>, last_stroke_texture: Item<Raster<CPU>>) {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.cache_results(input, blended_image, last_stroke_texture)
|
||||
}
|
||||
|
||||
pub fn get_cached_brush(&self, style: &BrushStyle) -> Option<Raster<CPU>> {
|
||||
let inner = self.0.lock().unwrap();
|
||||
inner.brush_texture_cache.get(&CacheHashWrapper(style.clone())).cloned()
|
||||
}
|
||||
|
||||
pub fn store_brush(&self, style: BrushStyle, brush: Raster<CPU>) {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.brush_texture_cache.insert(CacheHashWrapper(style), brush);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
use core_types::CacheHash;
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::color::Color;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use dyn_any::DynAny;
|
||||
use glam::DVec2;
|
||||
/// The style of a brush.
|
||||
#[derive(Clone, Debug, CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct BrushStyle {
|
||||
pub color: Color,
|
||||
pub diameter: f64,
|
||||
pub hardness: f64,
|
||||
pub flow: f64,
|
||||
pub spacing: f64, // Spacing as a fraction of the diameter.
|
||||
pub blend_mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for BrushStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color: Color::BLACK,
|
||||
diameter: 40.,
|
||||
hardness: 50.,
|
||||
flow: 100.,
|
||||
spacing: 50., // Percentage of diameter.
|
||||
blend_mode: BlendMode::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for BrushStyle {}
|
||||
|
||||
impl PartialEq for BrushStyle {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.color == other.color
|
||||
&& self.diameter.to_bits() == other.diameter.to_bits()
|
||||
&& self.hardness.to_bits() == other.hardness.to_bits()
|
||||
&& self.flow.to_bits() == other.flow.to_bits()
|
||||
&& self.spacing.to_bits() == other.spacing.to_bits()
|
||||
&& self.blend_mode == other.blend_mode
|
||||
}
|
||||
}
|
||||
|
||||
/// A single sample of brush parameters across the brush stroke.
|
||||
#[derive(Clone, Debug, PartialEq, core_types::CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct BrushInputSample {
|
||||
pub position: DVec2,
|
||||
}
|
||||
|
||||
/// The parameters for a single stroke brush.
|
||||
#[derive(Clone, Debug, PartialEq, core_types::CacheHash, Default, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct BrushStroke {
|
||||
pub style: BrushStyle,
|
||||
pub trace: Vec<BrushInputSample>,
|
||||
}
|
||||
|
||||
/// One Brush layer's full sequence of strokes, treated as a single rank-0 value rather than a frame of independent strokes.
|
||||
#[derive(Default, Debug, Clone, PartialEq, CacheHash, DynAny)]
|
||||
pub struct BrushTrace(pub List<BrushStroke>);
|
||||
|
||||
impl From<List<BrushStroke>> for BrushTrace {
|
||||
fn from(strokes: List<BrushStroke>) -> Self {
|
||||
Self(strokes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<BrushStroke>> for BrushTrace {
|
||||
fn from(strokes: Vec<BrushStroke>) -> Self {
|
||||
Self(strokes.into_iter().map(Item::new_from_element).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl BrushStroke {
|
||||
pub fn bounding_box(&self) -> AxisAlignedBbox {
|
||||
let radius = self.style.diameter / 2.;
|
||||
self.compute_blit_points()
|
||||
.iter()
|
||||
.map(|pos| AxisAlignedBbox {
|
||||
start: *pos + DVec2::new(-radius, -radius),
|
||||
end: *pos + DVec2::new(radius, radius),
|
||||
})
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap_or(AxisAlignedBbox::ZERO)
|
||||
}
|
||||
|
||||
pub fn compute_blit_points(&self) -> Vec<DVec2> {
|
||||
// We always travel in a straight line towards the next user input,
|
||||
// placing a blit point every time we travelled our spacing distance.
|
||||
let spacing_dist = self.style.spacing / 100. * self.style.diameter;
|
||||
|
||||
let Some(first_sample) = self.trace.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut cur_pos = first_sample.position;
|
||||
let mut result = vec![cur_pos];
|
||||
let mut dist_until_next_blit = spacing_dist;
|
||||
for sample in &self.trace[1..] {
|
||||
// Travel to the next sample.
|
||||
let delta = sample.position - cur_pos;
|
||||
let mut dist_left = delta.length();
|
||||
let unit_step = delta / dist_left;
|
||||
|
||||
while dist_left >= dist_until_next_blit {
|
||||
// Take a step to the next blit point.
|
||||
cur_pos += dist_until_next_blit * unit_step;
|
||||
dist_left -= dist_until_next_blit;
|
||||
|
||||
// Blit.
|
||||
result.push(cur_pos);
|
||||
dist_until_next_blit = spacing_dist;
|
||||
}
|
||||
|
||||
// Take the partial step to land at the sample.
|
||||
dist_until_next_blit -= dist_left;
|
||||
cur_pos = sample.position;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,23 @@ use core_types::registry::types::Percentage;
|
||||
use core_types::{Color, Ctx};
|
||||
use graphic_types::Graphic;
|
||||
|
||||
pub mod brush;
|
||||
mod brush_cache;
|
||||
pub mod brush_stroke;
|
||||
pub mod basic_brush;
|
||||
|
||||
pub use brush_types::*;
|
||||
|
||||
pub(crate) const DEFAULT_DIAMETER: f64 = 40.;
|
||||
pub(crate) const DEFAULT_HARDNESS: f64 = 0.;
|
||||
pub(crate) const DEFAULT_FLOW: f64 = 100.;
|
||||
pub(crate) const DEFAULT_COLOR: Color = Color::BLACK;
|
||||
|
||||
#[node_macro::node(category("Raster: Brush"))]
|
||||
fn brush_strokes(
|
||||
_: impl Ctx,
|
||||
strokes: List<Stroke>,
|
||||
color: List<Color>,
|
||||
#[default(40.)] diameter: Item<f64>,
|
||||
#[default(0.)] hardness: Item<Percentage>,
|
||||
#[default(100.)] flow: Item<Percentage>,
|
||||
#[default(DEFAULT_DIAMETER)] diameter: Item<f64>,
|
||||
#[default(DEFAULT_HARDNESS)] hardness: Item<Percentage>,
|
||||
#[default(DEFAULT_FLOW)] flow: Item<Percentage>,
|
||||
) -> List<Graphic> {
|
||||
let (diameter, hardness, flow) = (diameter.into_element(), hardness.into_element(), flow.into_element());
|
||||
List::new_from_item(
|
||||
@@ -27,30 +30,3 @@ fn brush_strokes(
|
||||
.with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)),
|
||||
)
|
||||
}
|
||||
|
||||
pub mod migrations {
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LegacyTable {
|
||||
#[serde(alias = "instances", alias = "instance")]
|
||||
element: Vec<BrushStroke>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BrushStrokesFormat {
|
||||
Strokes(Vec<BrushStroke>),
|
||||
List(LegacyTable),
|
||||
}
|
||||
|
||||
Ok(match BrushStrokesFormat::deserialize(deserializer)? {
|
||||
BrushStrokesFormat::Strokes(strokes) => strokes,
|
||||
BrushStrokesFormat::List(list) => list.element,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user