Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,15 @@ impl TableItemLayout for Artboard {
}
}

impl TableItemLayout for graphene_std::brush::Stroke {
fn type_name() -> &'static str {
"Stroke"
}
fn identifier(&self) -> String {
format!("Stroke ({} {})", self.len(), if self.len() == 1 { "sample" } else { "samples" })
}
}

impl TableItemLayout for DashPattern {
fn type_name() -> &'static str {
"DashPattern"
Expand Down Expand Up @@ -605,6 +614,7 @@ impl TableItemLayout for Graphic {
Self::ColorList(list) => list.identifier(),
Self::GradientList(list) => list.identifier(),
Self::TextList(list) => list.identifier(),
Self::StrokeList(list) => list.identifier(),
}
}
// Don't put a breadcrumb for Graphic
Expand All @@ -629,6 +639,7 @@ impl TableItemLayout for Graphic {
Self::ColorList(list) => list.layout_with_breadcrumb(data),
Self::GradientList(list) => list.layout_with_breadcrumb(data),
Self::TextList(list) => list.layout_with_breadcrumb(data),
Self::StrokeList(list) => list.layout_with_breadcrumb(data),
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions node-graph/graph-craft/src/document/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, BrushTrace};
use core_types::color::SRGBA8;
use core_types::list::{Item, List, NodeIdPath};
Expand Down Expand Up @@ -97,6 +98,7 @@ macro_rules! tagged_value {
#[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>),
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -140,6 +142,7 @@ macro_rules! tagged_value {
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),
// =======================
// NON-SERIALIZED VARIANTS
// =======================
Expand Down Expand Up @@ -203,6 +206,10 @@ macro_rules! tagged_value {
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)
}
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -266,6 +273,10 @@ macro_rules! tagged_value {
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)
}
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -295,6 +306,7 @@ macro_rules! tagged_value {
Self::BoxCorners(_) => item!(BoxCorners),
Self::GradientRamp(_) => item!(Gradient),
Self::BrushStrokes(_) => item!(BrushTrace),
Self::Strokes(_) => list!(Stroke),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a List<Stroke> input needs a default, TaggedValue::from_type(&list!(Stroke)) returns None, so new brush nodes receive TaggedValue::None instead of an empty stroke list. Add a Stroke case in the Type::List branch returning TaggedValue::Strokes(Vec::new()).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/graph-craft/src/document/value.rs, line 309:

<comment>When a `List<Stroke>` input needs a default, `TaggedValue::from_type(&list!(Stroke))` returns `None`, so new brush nodes receive `TaggedValue::None` instead of an empty stroke list. Add a `Stroke` case in the `Type::List` branch returning `TaggedValue::Strokes(Vec::new())`.</comment>

<file context>
@@ -295,6 +306,7 @@ macro_rules! tagged_value {
 					Self::BoxCorners(_) => item!(BoxCorners),
 					Self::GradientRamp(_) => item!(Gradient),
 					Self::BrushStrokes(_) => item!(BrushTrace),
+					Self::Strokes(_) => list!(Stroke),
 					// =======================
 					// AUTO-GENERATED VARIANTS
</file context>

// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -335,6 +347,7 @@ macro_rules! tagged_value {
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())),
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -369,6 +382,7 @@ macro_rules! tagged_value {
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())),
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down Expand Up @@ -397,6 +411,7 @@ macro_rules! tagged_value {
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())) }
// Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time
macro_rules! check_bare {
($type_default:ty) => {
Expand All @@ -423,6 +438,9 @@ macro_rules! tagged_value {
if **element == concrete!(f64) {
return Some(TaggedValue::F64Array(Vec::new()));
}
if **element == concrete!(Stroke) {
return Some(TaggedValue::Strokes(Vec::new()));
}
macro_rules! check {
($type_default:ty) => {
if **element == concrete!($type_default) { return Some(TaggedValue::TypeDefault(input.clone())); }
Expand Down Expand Up @@ -450,6 +468,7 @@ macro_rules! tagged_value {
Self::BoxCorners(values) => format!("BoxCorners({values:?})"),
Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"),
Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"),
Self::Strokes(strokes) => format!("Strokes({strokes:?})"),
// =======================
// AUTO-GENERATED VARIANTS
// =======================
Expand Down
2 changes: 1 addition & 1 deletion node-graph/graph-craft/src/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)]
vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)]
);
}

Expand Down
4 changes: 4 additions & 0 deletions node-graph/interpreted-executor/src/node_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use graph_craft::document::value::RenderOutput;
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;
Expand Down Expand Up @@ -82,6 +83,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
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")]
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<&PlatformEditorApi>, Context => Item<graphene_std::ContextFeatures>]),
Expand Down Expand Up @@ -145,6 +147,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
#[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>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<Option<&wgpu_executor::WgpuExecutor>>]),
Expand Down Expand Up @@ -353,6 +356,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
RelativeAbsolute,
SelectiveColorChoice,
BrushTrace,
Stroke,
XY,
ScaleType,
ReferencePoint,
Expand Down
23 changes: 23 additions & 0 deletions node-graph/libraries/brush-types/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <contact@graphite.art>"]
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 }
131 changes: 131 additions & 0 deletions node-graph/libraries/brush-types/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use core_types::CacheHash;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2, Vec2};
use std::f32::consts::{PI, TAU};

#[derive(Clone, Debug, PartialEq, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Channel<T> {
Uniform(T),
Samples(Vec<T>),
}

impl<T: Copy> Channel<T> {
pub fn get(&self, index: usize) -> T {
match self {
Self::Uniform(value) => *value,
Self::Samples(values) => values[index],
}
}

fn len(&self) -> Option<usize> {
match self {
Self::Uniform(_) => None,
Self::Samples(values) => Some(values.len()),
}
}
}

unsafe impl<T: dyn_any::StaticTypeSized> dyn_any::StaticType for Channel<T> {
type Static = Channel<T::Static>;
}

#[derive(Clone, Debug, PartialEq, CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stroke {
pub position: Vec<DVec2>,
pub pressure: Channel<f32>,
pub tilt: Channel<Vec2>,
pub twist: Channel<f32>,
pub time: Channel<f64>,
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Calling sample with an out-of-range index panics, including Stroke::default().sample(0). Return an Option<Sample> or Result and handle invalid channel lengths instead of indexing unchecked.

(Based on your team's feedback about avoiding panics in application code.) [b5917bc3-2dbc-49a4-8246-5cad0a2e4976]

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/libraries/brush-types/src/lib.rs, line 73:

<comment>Calling `sample` with an out-of-range index panics, including `Stroke::default().sample(0)`. Return an `Option<Sample>` or `Result` and handle invalid channel lengths instead of indexing unchecked.

(Based on your team's feedback about avoiding panics in application code.) [b5917bc3-2dbc-49a4-8246-5cad0a2e4976]</comment>

<file context>
@@ -0,0 +1,131 @@
+		[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],
</file context>

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)));
Sample {
position: a.position.lerp(b.position, t as f64),
pressure: a.pressure + (b.pressure - a.pressure) * t,
tilt: a.tilt.lerp(b.tilt, 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,
}
}

pub fn samples(&self) -> impl Iterator<Item = Sample> + '_ {
(0..self.len()).map(|index| self.sample(index))
}
}

impl BoundingBox for Stroke {
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a brush stroke has nonzero diameter, this bound covers only centerline sample points. BrushStrokes stores diameter on the containing item, so clipping and layer bounds exclude the painted footprint; add style-aware inflation at the item level.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/libraries/brush-types/src/lib.rs, line 105:

<comment>When a brush stroke has nonzero diameter, this bound covers only centerline sample points. `BrushStrokes` stores diameter on the containing item, so clipping and layer bounds exclude the painted footprint; add style-aware inflation at the item level.</comment>

<file context>
@@ -0,0 +1,131 @@
+}
+
+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)));
</file context>

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,
}
Loading
Loading