From 7d2cd8c743006436f70a46c5f206b95648dd90a1 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 24 Jul 2026 17:13:00 +0000 Subject: [PATCH 1/3] Add the brush stroke format types --- Cargo.lock | 11 ++ Cargo.toml | 1 + node-graph/libraries/brush-types/Cargo.toml | 23 +++ node-graph/libraries/brush-types/src/lib.rs | 165 ++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 node-graph/libraries/brush-types/Cargo.toml create mode 100644 node-graph/libraries/brush-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4abe10233e..3452a9dde1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "brush-types" +version = "0.1.0" +dependencies = [ + "core-types", + "dyn-any", + "glam", + "graphene-hash", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" diff --git a/Cargo.toml b/Cargo.toml index 4897d1e9d8..e965ef6f4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ no-std-types = { path = "node-graph/libraries/no-std-types" } raster-types = { path = "node-graph/libraries/raster-types" } vector-types = { path = "node-graph/libraries/vector-types" } graphic-types = { path = "node-graph/libraries/graphic-types" } +brush-types = { path = "node-graph/libraries/brush-types" } rendering = { path = "node-graph/libraries/rendering" } brush-nodes = { path = "node-graph/nodes/brush" } blending-nodes = { path = "node-graph/nodes/blending" } diff --git a/node-graph/libraries/brush-types/Cargo.toml b/node-graph/libraries/brush-types/Cargo.toml new file mode 100644 index 0000000000..82ec176975 --- /dev/null +++ b/node-graph/libraries/brush-types/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "brush-types" +version = "0.1.0" +edition = "2024" +description = "The brush stroke data format for Graphene" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[features] +default = ["serde"] +serde = ["dep:serde", "core-types/serde"] + +[dependencies] +# Local dependencies +core-types = { workspace = true } +graphene-hash = { workspace = true } + +# Workspace dependencies +dyn-any = { workspace = true } +glam = { workspace = true } + +# Optional workspace dependencies +serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/brush-types/src/lib.rs b/node-graph/libraries/brush-types/src/lib.rs new file mode 100644 index 0000000000..f96c272519 --- /dev/null +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -0,0 +1,165 @@ +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::render_complexity::RenderComplexity; +use core_types::{CacheHash, Color}; +use dyn_any::DynAny; +use glam::{DAffine2, DVec2, Vec2}; +use std::f32::consts::{PI, TAU}; + +#[derive(Clone, Copy, Debug, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BrushStyle { + pub color: Color, + pub diameter: f64, + pub hardness: f64, + pub flow: f64, +} + +impl Default for BrushStyle { + fn default() -> Self { + Self { + color: Color::BLACK, + diameter: 20., + hardness: 0.8, + flow: 1., + } + } +} + +#[derive(Clone, Debug, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Channel { + Uniform(T), + Samples(Vec), +} + +impl Channel { + pub fn get(&self, index: usize) -> T { + match self { + Self::Uniform(value) => *value, + Self::Samples(values) => values[index], + } + } + + pub fn len(&self) -> Option { + match self { + Self::Uniform(_) => None, + Self::Samples(values) => Some(values.len()), + } + } + + pub fn is_uniform(&self) -> bool { + matches!(self, Self::Uniform(_)) + } + + pub fn is_empty(&self) -> bool { + match self { + Self::Uniform(_) => false, + Self::Samples(values) => values.is_empty(), + } + } +} + +unsafe impl dyn_any::StaticType for Channel { + type Static = Channel; +} + +#[derive(Clone, Debug, PartialEq, CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Stroke { + pub position: Vec, + pub pressure: Channel, + pub tilt: Channel, + pub twist: Channel, + pub time: Channel, + pub seed: u64, +} + +impl Default for Stroke { + fn default() -> Self { + Self { + position: Vec::new(), + pressure: Channel::Uniform(1.), + tilt: Channel::Uniform(Vec2::ZERO), + twist: Channel::Uniform(0.), + time: Channel::Uniform(0.), + seed: 0, + } + } +} + +impl Stroke { + pub fn len(&self) -> usize { + self.position.len() + } + + pub fn is_empty(&self) -> bool { + self.position.is_empty() + } + + pub fn is_valid(&self) -> bool { + let n = self.len(); + [self.pressure.len(), self.tilt.len(), self.twist.len(), self.time.len()].into_iter().flatten().all(|len| len == n) + } + + pub fn sample(&self, index: usize) -> Sample { + Sample { + position: self.position[index], + pressure: self.pressure.get(index), + tilt: self.tilt.get(index), + twist: self.twist.get(index), + time: self.time.get(index), + } + } + + pub fn sample_lerp(&self, index: usize, t: f32) -> Sample { + let a = self.sample(index); + let b = self.sample((index + 1).min(self.len().saturating_sub(1))); + let lerp = |a: f32, b: f32| a + (b - a) * t; + Sample { + position: a.position.lerp(b.position, t as f64), + pressure: lerp(a.pressure, b.pressure), + tilt: a.tilt.lerp(b.tilt, t), + twist: lerp_angle(a.twist, b.twist, t), + time: a.time + (b.time - a.time) * t as f64, + } + } + + pub fn samples(&self) -> impl Iterator + '_ { + (0..self.len()).map(|index| self.sample(index)) + } +} + +impl BoundingBox for Stroke { + fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + let Some(first) = self.position.first() else { return RenderBoundingBox::None }; + let (min, max) = self.position.iter().fold((*first, *first), |(min, max), &point| (min.min(point), max.max(point))); + let corners = [min, DVec2::new(max.x, min.y), max, DVec2::new(min.x, max.y)].map(|corner| transform.transform_point2(corner)); + let (min, max) = corners.iter().fold((corners[0], corners[0]), |(min, max), &point| (min.min(point), max.max(point))); + RenderBoundingBox::Rectangle([min, max]) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + self.bounding_box(transform, include_stroke) + } +} + +impl RenderComplexity for Stroke { + fn render_complexity(&self) -> usize { + self.len() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sample { + pub position: DVec2, + pub pressure: f32, + pub tilt: Vec2, + pub twist: f32, + pub time: f64, +} + +fn lerp_angle(a: f32, b: f32, t: f32) -> f32 { + let delta = (b - a).rem_euclid(TAU); + let delta = if delta > PI { delta - TAU } else { delta }; + a + delta * t +} From edeeec26a853a847db534a84919790429a98069c Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 24 Jul 2026 17:20:58 +0000 Subject: [PATCH 2/3] Route brush strokes through the node graph --- Cargo.lock | 5 +++ .../data_panel/data_panel_message_handler.rs | 15 +++++++ node-graph/graph-craft/src/document/value.rs | 14 ++++++ node-graph/graph-craft/src/proto.rs | 2 +- .../interpreted-executor/src/node_registry.rs | 2 + node-graph/libraries/core-types/src/list.rs | 2 + node-graph/libraries/graphic-types/Cargo.toml | 1 + .../libraries/graphic-types/src/graphic.rs | 43 +++++++++++++++++-- node-graph/libraries/rendering/Cargo.toml | 3 +- .../libraries/rendering/src/render_ext.rs | 2 +- .../libraries/rendering/src/renderer.rs | 25 ++++++++++- node-graph/nodes/brush/Cargo.toml | 2 + node-graph/nodes/brush/src/brush_strokes.rs | 15 +++++++ node-graph/nodes/brush/src/lib.rs | 3 ++ node-graph/nodes/graphic/Cargo.toml | 1 + node-graph/nodes/graphic/src/graphic.rs | 7 ++- node-graph/nodes/path-bool/src/lib.rs | 2 + 17 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 node-graph/nodes/brush/src/brush_strokes.rs diff --git a/Cargo.lock b/Cargo.lock index 3452a9dde1..00a9fcea30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,10 +370,12 @@ dependencies = [ name = "brush-nodes" version = "0.1.0" dependencies = [ + "brush-types", "core-types", "dyn-any", "glam", "graphene-hash", + "graphic-types", "node-macro", "raster-nodes", "raster-types", @@ -2196,6 +2198,7 @@ dependencies = [ name = "graphic-nodes" version = "0.1.0" dependencies = [ + "brush-types", "core-types", "dyn-any", "glam", @@ -2210,6 +2213,7 @@ dependencies = [ name = "graphic-types" version = "0.1.0" dependencies = [ + "brush-types", "core-types", "dyn-any", "glam", @@ -4884,6 +4888,7 @@ name = "rendering" version = "0.1.0" dependencies = [ "base64", + "brush-types", "core-types", "dyn-any", "glam", diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index c66d3f9810..c6d16ab513 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -337,6 +337,7 @@ impl TableItemLayout for Graphic { Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), Self::Text(list) => list.identifier(), + Self::Stroke(list) => list.identifier(), } } // Don't put a breadcrumb for Graphic @@ -352,6 +353,7 @@ impl TableItemLayout for Graphic { Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), Self::Text(list) => list.layout_with_breadcrumb(data), + Self::Stroke(list) => list.layout_with_breadcrumb(data), } } } @@ -557,6 +559,19 @@ impl TableItemLayout for GradientStops { } } +impl TableItemLayout for graphene_std::brush::Stroke { + fn type_name() -> &'static str { + "Stroke" + } + fn identifier(&self) -> String { + let samples = self.len(); + format!("Stroke ({} sample{})", samples, if samples == 1 { "" } else { "s" }) + } + fn value_page(&self, _data: &mut LayoutData) -> Vec { + vec![LayoutGroup::row(vec![TextAreaInput::new(format!("{self:#?}")).monospace(true).disabled(true).widget_instance()])] + } +} + impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number (f64)" diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index c3af90cf23..a34d28d062 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,6 +2,7 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; +use brush_nodes::Stroke; use brush_nodes::brush_stroke::BrushStroke; use core_types::color::SRGBA8; use core_types::list::List; @@ -73,6 +74,7 @@ macro_rules! tagged_value { #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "BrushStrokeTable")] BrushStrokes(Vec), + Strokes(Vec), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -115,6 +117,7 @@ macro_rules! tagged_value { Self::Color(color) => color.cache_hash(state), Self::Gradient(stops) => stops.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), + Self::Strokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS // ======================= @@ -162,6 +165,10 @@ macro_rules! tagged_value { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Box::new(list) } + Self::Strokes(strokes) => { + let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); + Box::new(list) + } // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -212,6 +219,10 @@ macro_rules! tagged_value { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Arc::new(list) } + Self::Strokes(strokes) => { + let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); + Arc::new(list) + } // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -243,6 +254,7 @@ macro_rules! tagged_value { Self::Color(_) => concrete!(List), Self::Gradient(_) => concrete!(List), Self::BrushStrokes(_) => concrete!(List), + Self::Strokes(_) => concrete!(List), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -320,6 +332,7 @@ macro_rules! tagged_value { $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::F64Array(Vec::new())) } if name == std::any::type_name::>() { return Some(TaggedValue::BrushStrokes(Vec::new())) } + if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } // Types whose `TaggedValue` variant has been removed. They route through `TypeDefault` instead, with `to_dynany`/`to_any` constructing the actual default at execution time. macro_rules! check { ($type_default:ty) => { @@ -349,6 +362,7 @@ macro_rules! tagged_value { Self::Color(color) => format!("Color({color:?})"), Self::Gradient(stops) => format!("Gradient({stops:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), + Self::Strokes(strokes) => format!("Strokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS // ======================= diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 2fa7eb93a3..f87e75d5f2 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -951,7 +951,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(12815475172301479638), NodeId(13251389748338817266), NodeId(7166921994790432021), NodeId(15318519137317483318)] + vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index d9ee2e6ad2..6f2c5fc6a5 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -161,6 +161,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Graphic]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text::Font]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DocumentNode]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::Footprint]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::blending::BlendMode]), @@ -250,6 +251,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::style::Stroke]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::Font]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => DocumentNode]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::ContextFeatures]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::transform::Footprint]), diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index b0d5888115..ad22700a60 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -62,6 +62,8 @@ pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. pub const ATTR_STROKE: &str = "stroke"; +/// Brush stroke item's `brush_types::BrushStyle` — the brush settings its strokes were drawn with. +pub const ATTR_BRUSH_STYLE: &str = "brush_style"; /// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; /// Text item's font, as a `Resource` of the loaded font file. diff --git a/node-graph/libraries/graphic-types/Cargo.toml b/node-graph/libraries/graphic-types/Cargo.toml index 6c9e0c23c4..0762710a89 100644 --- a/node-graph/libraries/graphic-types/Cargo.toml +++ b/node-graph/libraries/graphic-types/Cargo.toml @@ -20,6 +20,7 @@ wasm = [ # Local dependencies core-types = { workspace = true } graphene-hash = { workspace = true } +brush-types = { workspace = true } raster-types = { workspace = true, features = ["wgpu"] } vector-types = { workspace = true } node-macro = { workspace = true } diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index b4c51561bb..969c29a499 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,3 +1,4 @@ +use brush_types::Stroke; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; use core_types::list::{ATTR_FILL, ATTR_STROKE, ItemAttributeValues, List}; @@ -22,6 +23,7 @@ pub enum Graphic { Color(List), Gradient(List), Text(List), + Stroke(List), } impl Default for Graphic { @@ -103,7 +105,19 @@ impl From> for Graphic { } } -// String +// Stroke +impl From for Graphic { + fn from(stroke: Stroke) -> Self { + Graphic::Stroke(List::new_from_element(stroke)) + } +} +impl From> for Graphic { + fn from(stroke: List) -> Self { + Graphic::Stroke(stroke) + } +} + +// Text impl From for Graphic { fn from(text: String) -> Self { Graphic::Text(List::new_from_element(text)) @@ -238,6 +252,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform), + Graphic::Stroke(list) => bake_list_transform(list, transform), Graphic::Color(_) => {} } } @@ -286,6 +301,12 @@ impl TryFromGraphic for String { } } +impl TryFromGraphic for Stroke { + fn try_from_graphic(graphic: Graphic) -> Option> { + if let Graphic::Stroke(t) = graphic { Some(t) } else { None } + } +} + // Local trait to convert types to List (avoids orphan rule issues) pub trait IntoGraphicList: Clone + Send + Sync + Default + std::fmt::Debug + PartialEq + CacheHash + 'static { fn into_graphic_list(self) -> List; @@ -342,6 +363,17 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + let layer_path: List = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); + let mut graphic_list = List::new_from_element(Graphic::Stroke(self)); + if !layer_path.is_empty() { + graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path); + } + graphic_list + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path: List = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); @@ -430,6 +462,7 @@ impl Graphic { Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), Graphic::Text(list) => all_clipped(list), + Graphic::Stroke(list) => all_clipped(list), } } @@ -468,7 +501,7 @@ impl Graphic { } Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::Stroke(_) => false, } } @@ -491,7 +524,7 @@ impl Graphic { }), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::Stroke(_) => false, } } @@ -511,6 +544,7 @@ impl Graphic { Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), Graphic::Text(list) => list.is_empty(), + Graphic::Stroke(list) => list.is_empty(), } } } @@ -525,6 +559,7 @@ impl BoundingBox for Graphic { Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), Graphic::Text(list) => list.bounding_box(transform, include_stroke), + Graphic::Stroke(list) => list.bounding_box(transform, include_stroke), } } @@ -537,6 +572,7 @@ impl BoundingBox for Graphic { Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), + Graphic::Stroke(list) => list.thumbnail_bounding_box(transform, include_stroke), } } } @@ -567,6 +603,7 @@ impl RenderComplexity for Graphic { Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), Self::Text(list) => list.render_complexity(), + Self::Stroke(list) => list.render_complexity(), } } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 13facc359c..7da33a4eb0 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -8,12 +8,13 @@ license = "MIT OR Apache-2.0" [features] default = ["serde"] -serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/serde"] +serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/serde", "brush-types/serde"] [dependencies] # Local dependencies dyn-any = { workspace = true } core-types = { workspace = true } +brush-types = { workspace = true } graphene-hash = { workspace = true } graphene-resource = { workspace = true } text-nodes = { workspace = true } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index f25bb069ff..25d69b2f83 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -241,7 +241,7 @@ impl RenderExt for List { let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target); format!(r##" {paint_attr}="url(#{gradient_id})""##) } - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::Stroke(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 9640f27aca..b284959b8d 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -553,6 +553,7 @@ impl Render for Graphic { Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), + Graphic::Stroke(_) => (), } } @@ -565,6 +566,7 @@ impl Render for Graphic { Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::Stroke(_) => (), } } @@ -621,6 +623,14 @@ impl Render for Graphic { Graphic::Text(list) => { metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } + Graphic::Stroke(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); @@ -637,6 +647,7 @@ impl Render for Graphic { Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::Stroke(list) => list.collect_metadata(metadata, footprint, element_id), } } @@ -649,6 +660,7 @@ impl Render for Graphic { Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), + Graphic::Stroke(list) => list.add_upstream_click_targets(click_targets), } } @@ -661,6 +673,7 @@ impl Render for Graphic { Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), + Graphic::Stroke(list) => list.add_upstream_outline_targets(outlines), } } @@ -673,6 +686,7 @@ impl Render for Graphic { Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), + Graphic::Stroke(list) => list.contains_artboard(), } } @@ -685,6 +699,7 @@ impl Render for Graphic { Graphic::Color(_) => (), Graphic::Gradient(_) => (), Graphic::Text(_) => (), + Graphic::Stroke(_) => (), } } } @@ -1367,7 +1382,7 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Stroke(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); @@ -1449,7 +1464,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Stroke(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); @@ -1986,6 +2001,12 @@ impl Render for List> { } } +impl Render for List { + fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {} + + fn render_to_vello(&self, _scene: &mut Scene, _transform: DAffine2, _context: &mut RenderContext, _render_params: &RenderParams) {} +} + // Since colors and gradients are technically infinitely big, we have to implement // workarounds for rendering them correctly in a way which still allows us // to cache the intermediate render data (SVG string/Vello scene). diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index e48a52e85a..f9b467b57f 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -13,8 +13,10 @@ serde = ["dep:serde", "core-types/serde", "raster-types/serde", "raster-nodes/se [dependencies] # Local dependencies dyn-any = { workspace = true } +brush-types = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } +graphic-types = { workspace = true } raster-types = { workspace = true } raster-nodes = { workspace = true } node-macro = { workspace = true } diff --git a/node-graph/nodes/brush/src/brush_strokes.rs b/node-graph/nodes/brush/src/brush_strokes.rs new file mode 100644 index 0000000000..e309df8397 --- /dev/null +++ b/node-graph/nodes/brush/src/brush_strokes.rs @@ -0,0 +1,15 @@ +use brush_types::{BrushStyle, Stroke}; +use core_types::list::{ATTR_BRUSH_STYLE, Item, List}; +use core_types::{Color, Ctx}; +use graphic_types::Graphic; + +#[node_macro::node(category("Raster: Brush"))] +fn brush_strokes(_: impl Ctx, strokes: List, color: List, #[default(20.)] diameter: f64, #[default(0.8)] hardness: f64, #[default(1.)] flow: f64) -> List { + let style = BrushStyle { + color: color.element(0).copied().unwrap_or_default(), + diameter: diameter.max(0.), + hardness: hardness.clamp(0., 1.), + flow: flow.clamp(0., 1.), + }; + List::new_from_item(Item::new_from_element(Graphic::from(strokes)).with_attribute(ATTR_BRUSH_STYLE, style)) +} diff --git a/node-graph/nodes/brush/src/lib.rs b/node-graph/nodes/brush/src/lib.rs index d9eae7b368..31e69e946c 100644 --- a/node-graph/nodes/brush/src/lib.rs +++ b/node-graph/nodes/brush/src/lib.rs @@ -1,6 +1,9 @@ pub mod brush; mod brush_cache; pub mod brush_stroke; +pub mod brush_strokes; + +pub use brush_types::*; pub mod migrations { use crate::brush_stroke::BrushStroke; diff --git a/node-graph/nodes/graphic/Cargo.toml b/node-graph/nodes/graphic/Cargo.toml index c67322948e..3608030884 100644 --- a/node-graph/nodes/graphic/Cargo.toml +++ b/node-graph/nodes/graphic/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true [dependencies] # Local dependencies core-types = { workspace = true } +brush-types = { workspace = true } graphic-types = { workspace = true } vector-types = { workspace = true } raster-types = { workspace = true } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 93ab6a4079..b6690dd7dc 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -1,3 +1,4 @@ +use brush_types::Stroke; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{AttributeDyn, AttributeValueDyn, Item, List, ListDyn}; use core_types::registry::types::{Angle, SignedInteger}; @@ -500,11 +501,11 @@ fn read_attribute_raster( pub async fn extend( _: impl Ctx, /// The `List` whose items will appear at the start of the extended `List`. - #[implementations(List, List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List, List)] base: List, /// The `List` whose items will appear at the end of the extended `List`. #[expose] - #[implementations(List, List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List, List)] new: List, ) -> List { let mut base = base; @@ -556,6 +557,7 @@ pub async fn wrap_graphic + 'n>( List, DAffine2, DVec2, + List, )] content: T, ) -> List { @@ -575,6 +577,7 @@ pub async fn to_graphic( List, List, List, + List, )] content: T, ) -> List { diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 14e2270f98..560d7c76dd 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -321,6 +321,8 @@ fn flatten_vector(graphic_list: &List) -> List { }) .collect::>() } + // Strokes have no vector outline representation; a brush node renders them to rasters + Graphic::Stroke(_) => Vec::new(), } }) .collect() From 896cf2ef00a826bd14970ebd709db31ebfb446ea Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 20 Aug 2026 13:18:50 +0000 Subject: [PATCH 3/3] Store brush style as per-item attributes instead of a struct --- node-graph/libraries/brush-types/src/lib.rs | 50 ++++----------------- node-graph/libraries/core-types/src/list.rs | 10 ++++- node-graph/nodes/brush/src/brush_strokes.rs | 15 ------- node-graph/nodes/brush/src/lib.rs | 24 +++++++++- 4 files changed, 39 insertions(+), 60 deletions(-) delete mode 100644 node-graph/nodes/brush/src/brush_strokes.rs diff --git a/node-graph/libraries/brush-types/src/lib.rs b/node-graph/libraries/brush-types/src/lib.rs index f96c272519..023fb29176 100644 --- a/node-graph/libraries/brush-types/src/lib.rs +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -1,30 +1,10 @@ +use core_types::CacheHash; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::render_complexity::RenderComplexity; -use core_types::{CacheHash, Color}; use dyn_any::DynAny; use glam::{DAffine2, DVec2, Vec2}; use std::f32::consts::{PI, TAU}; -#[derive(Clone, Copy, Debug, PartialEq, CacheHash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct BrushStyle { - pub color: Color, - pub diameter: f64, - pub hardness: f64, - pub flow: f64, -} - -impl Default for BrushStyle { - fn default() -> Self { - Self { - color: Color::BLACK, - diameter: 20., - hardness: 0.8, - flow: 1., - } - } -} - #[derive(Clone, Debug, PartialEq, CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Channel { @@ -40,23 +20,12 @@ impl Channel { } } - pub fn len(&self) -> Option { + fn len(&self) -> Option { match self { Self::Uniform(_) => None, Self::Samples(values) => Some(values.len()), } } - - pub fn is_uniform(&self) -> bool { - matches!(self, Self::Uniform(_)) - } - - pub fn is_empty(&self) -> bool { - match self { - Self::Uniform(_) => false, - Self::Samples(values) => values.is_empty(), - } - } } unsafe impl dyn_any::StaticType for Channel { @@ -114,12 +83,15 @@ impl Stroke { pub fn sample_lerp(&self, index: usize, t: f32) -> Sample { let a = self.sample(index); let b = self.sample((index + 1).min(self.len().saturating_sub(1))); - let lerp = |a: f32, b: f32| a + (b - a) * t; Sample { position: a.position.lerp(b.position, t as f64), - pressure: lerp(a.pressure, b.pressure), + pressure: a.pressure + (b.pressure - a.pressure) * t, tilt: a.tilt.lerp(b.tilt, t), - twist: lerp_angle(a.twist, b.twist, t), + twist: { + let delta = (b.twist - a.twist).rem_euclid(TAU); + let delta = if delta > PI { delta - TAU } else { delta }; + a.twist + delta * t + }, time: a.time + (b.time - a.time) * t as f64, } } @@ -157,9 +129,3 @@ pub struct Sample { pub twist: f32, pub time: f64, } - -fn lerp_angle(a: f32, b: f32, t: f32) -> f32 { - let delta = (b - a).rem_euclid(TAU); - let delta = if delta > PI { delta - TAU } else { delta }; - a + delta * t -} diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index ad22700a60..e71ec896d7 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -62,8 +62,14 @@ pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. pub const ATTR_STROKE: &str = "stroke"; -/// Brush stroke item's `brush_types::BrushStyle` — the brush settings its strokes were drawn with. -pub const ATTR_BRUSH_STYLE: &str = "brush_style"; +/// Brush stroke item's `Color` its strokes are painted with. +pub const ATTR_COLOR: &str = "color"; +/// Brush stroke item's tip diameter in document-space units (`f64`). +pub const ATTR_DIAMETER: &str = "diameter"; +/// Brush stroke item's edge hardness from `0.` (softest) to `1.` (hardest) (`f64`). +pub const ATTR_HARDNESS: &str = "hardness"; +/// Brush stroke item's per-pass paint coverage from `0.` to `1.` (`f64`). +pub const ATTR_FLOW: &str = "flow"; /// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; /// Text item's font, as a `Resource` of the loaded font file. diff --git a/node-graph/nodes/brush/src/brush_strokes.rs b/node-graph/nodes/brush/src/brush_strokes.rs deleted file mode 100644 index e309df8397..0000000000 --- a/node-graph/nodes/brush/src/brush_strokes.rs +++ /dev/null @@ -1,15 +0,0 @@ -use brush_types::{BrushStyle, Stroke}; -use core_types::list::{ATTR_BRUSH_STYLE, Item, List}; -use core_types::{Color, Ctx}; -use graphic_types::Graphic; - -#[node_macro::node(category("Raster: Brush"))] -fn brush_strokes(_: impl Ctx, strokes: List, color: List, #[default(20.)] diameter: f64, #[default(0.8)] hardness: f64, #[default(1.)] flow: f64) -> List { - let style = BrushStyle { - color: color.element(0).copied().unwrap_or_default(), - diameter: diameter.max(0.), - hardness: hardness.clamp(0., 1.), - flow: flow.clamp(0., 1.), - }; - List::new_from_item(Item::new_from_element(Graphic::from(strokes)).with_attribute(ATTR_BRUSH_STYLE, style)) -} diff --git a/node-graph/nodes/brush/src/lib.rs b/node-graph/nodes/brush/src/lib.rs index 31e69e946c..8d35ac799a 100644 --- a/node-graph/nodes/brush/src/lib.rs +++ b/node-graph/nodes/brush/src/lib.rs @@ -1,10 +1,32 @@ +use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List}; +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 brush_strokes; pub use brush_types::*; +#[node_macro::node(category("Raster: Brush"))] +fn brush_strokes( + _: impl Ctx, + strokes: List, + color: List, + #[default(40.)] diameter: f64, + #[default(0.)] hardness: Percentage, + #[default(100.)] flow: Percentage, +) -> List { + List::new_from_item( + Item::new_from_element(Graphic::from(strokes)) + .with_attribute(ATTR_COLOR, color.element(0).copied().unwrap_or_default()) + .with_attribute(ATTR_DIAMETER, diameter.max(0.)) + .with_attribute(ATTR_HARDNESS, (hardness / 100.).clamp(0., 1.)) + .with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)), + ) +} + pub mod migrations { use crate::brush_stroke::BrushStroke;