Introduce the new brush stroke data types (#4467)

* Add the brush stroke format types

* Route brush strokes through the node graph

* Store brush style as per-item attributes instead of a struct
This commit is contained in:
Timon
2026-08-27 15:19:01 +00:00
committed by GitHub
parent 96cc520c4b
commit 10256dd22c
19 changed files with 317 additions and 6 deletions

View File

@@ -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};
@@ -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
// =======================
@@ -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
// =======================
@@ -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
// =======================
@@ -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
// =======================
@@ -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
// =======================
@@ -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
// =======================
@@ -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
// =======================
@@ -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) => {
@@ -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())); }
@@ -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
// =======================

View File

@@ -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)]
);
}

View File

@@ -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;
@@ -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>]),
@@ -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>>]),
@@ -353,6 +356,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
RelativeAbsolute,
SelectiveColorChoice,
BrushTrace,
Stroke,
XY,
ScaleType,
ReferencePoint,

View File

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

View File

@@ -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 {
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 {
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,
}

View File

@@ -97,6 +97,14 @@ pub const ATTR_JOIN: &str = "join";
pub const ATTR_JOIN_MITER_LIMIT: &str = "join_miter_limit";
/// Stroke coverage's `StrokeAlign` (implicit default `Center`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_ALIGN: &str = "align";
/// 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.

View File

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

View File

@@ -1,4 +1,5 @@
use crate::appearance::{Appearance, Cover, Coverage};
use brush_types::Stroke;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_APPEARANCE, ATTR_PAINT, Item, ItemAttributeValues, List, NodeIdPath};
@@ -34,6 +35,7 @@ pub enum Graphic {
ColorList(List<Color>),
GradientList(List<Gradient>),
TextList(List<String>),
StrokeList(List<Stroke>),
}
impl Default for Graphic {
@@ -140,7 +142,19 @@ impl From<List<Gradient>> for Graphic {
}
}
// String
// Stroke
impl From<Stroke> for Graphic {
fn from(stroke: Stroke) -> Self {
Graphic::StrokeList(List::new_from_element(stroke))
}
}
impl From<List<Stroke>> for Graphic {
fn from(stroke: List<Stroke>) -> Self {
Graphic::StrokeList(stroke)
}
}
// Text
impl From<String> for Graphic {
fn from(text: String) -> Self {
Graphic::Text(Item::new_from_element(text))
@@ -310,6 +324,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
Graphic::RasterGPUList(list) => bake_list_transform(list, transform),
Graphic::GradientList(list) => bake_list_transform(list, transform),
Graphic::TextList(list) => bake_list_transform(list, transform),
Graphic::StrokeList(list) => bake_list_transform(list, transform),
// A color has no spatial extent, so there is no placement for a transform to move
Graphic::None(_) | Graphic::NoneList(_) | Graphic::Color(_) | Graphic::ColorList(_) => {}
}
@@ -380,6 +395,12 @@ impl TryFromGraphic for String {
}
}
impl TryFromGraphic for Stroke {
fn try_from_graphic(graphic: Graphic) -> Option<List<Self>> {
if let Graphic::StrokeList(t) = graphic { Some(t) } else { None }
}
}
// Local trait to convert types to List<Graphic> (avoids orphan rule issues)
pub trait IntoGraphicList: Clone + Send + Sync + Default + std::fmt::Debug + PartialEq + CacheHash + 'static {
fn into_graphic_list(self) -> List<Graphic>;
@@ -431,6 +452,17 @@ impl IntoGraphicList for List<Gradient> {
}
}
impl IntoGraphicList for List<Stroke> {
fn into_graphic_list(self) -> List<Graphic> {
let layer_path: NodeIdPath = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0);
let mut graphic_list = List::new_from_element(Graphic::StrokeList(self));
if !layer_path.0.is_empty() {
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
graphic_list
}
}
impl IntoGraphicList for List<String> {
fn into_graphic_list(self) -> List<Graphic> {
List::new_from_element(Graphic::TextList(self))
@@ -542,6 +574,7 @@ impl Graphic {
Graphic::ColorList(list) => all_clipped(list),
Graphic::GradientList(list) => all_clipped(list),
Graphic::TextList(list) => all_clipped(list),
Graphic::StrokeList(list) => all_clipped(list),
}
}
@@ -595,6 +628,7 @@ impl Graphic {
})
}
Graphic::Text(_) | Graphic::TextList(_) => false,
Graphic::StrokeList(_) => false,
}
}
@@ -627,6 +661,7 @@ impl Graphic {
Graphic::RasterCPUList(list) => every_item_has_zero_opacity(list),
Graphic::RasterGPUList(list) => every_item_has_zero_opacity(list),
Graphic::TextList(list) => every_item_has_zero_opacity(list),
Graphic::StrokeList(list) => every_item_has_zero_opacity(list),
}
}
@@ -648,6 +683,7 @@ impl Graphic {
Graphic::RasterCPUList(list) => list.is_empty(),
Graphic::RasterGPUList(list) => list.is_empty(),
Graphic::TextList(list) => list.is_empty(),
Graphic::StrokeList(list) => list.is_empty(),
}
}
}
@@ -802,6 +838,7 @@ impl BoundingBox for Graphic {
Graphic::ColorList(list) => list.bounding_box(transform, include_stroke),
Graphic::GradientList(list) => list.bounding_box(transform, include_stroke),
Graphic::TextList(list) => list.bounding_box(transform, include_stroke),
Graphic::StrokeList(list) => list.bounding_box(transform, include_stroke),
}
}
@@ -822,6 +859,7 @@ impl BoundingBox for Graphic {
Graphic::ColorList(color) => color.thumbnail_bounding_box(transform, include_stroke),
Graphic::GradientList(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke),
Graphic::TextList(list) => list.thumbnail_bounding_box(transform, include_stroke),
Graphic::StrokeList(list) => list.thumbnail_bounding_box(transform, include_stroke),
}
}
}
@@ -844,6 +882,7 @@ impl RenderComplexity for Graphic {
Self::ColorList(list) => list.render_complexity(),
Self::GradientList(list) => list.render_complexity(),
Self::TextList(list) => list.render_complexity(),
Self::StrokeList(list) => list.render_complexity(),
}
}
}

View File

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

View File

@@ -292,7 +292,8 @@ impl RenderExt for Graphic {
| Graphic::RasterGPUList(_)
| Graphic::GraphicList(_)
| Graphic::GradientList(_)
| Graphic::TextList(_) => {
| Graphic::TextList(_)
| Graphic::StrokeList(_) => {
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. };

View File

@@ -1066,6 +1066,7 @@ impl Render for Graphic {
Graphic::ColorList(list) => list.render_svg(render, render_params),
Graphic::GradientList(list) => list.render_svg(render, render_params),
Graphic::TextList(list) => list.render_svg(render, render_params),
Graphic::StrokeList(_) => (),
}
}
@@ -1093,6 +1094,7 @@ impl Render for Graphic {
Graphic::ColorList(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::GradientList(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::TextList(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::StrokeList(_) => (),
}
}
@@ -1163,6 +1165,14 @@ impl Render for Graphic {
Graphic::TextList(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::StrokeList(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));
@@ -1187,6 +1197,7 @@ impl Render for Graphic {
Graphic::ColorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::GradientList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::TextList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::StrokeList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
}
}
@@ -1207,6 +1218,7 @@ impl Render for Graphic {
Graphic::ColorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::GradientList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::TextList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::StrokeList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
}
}
@@ -1227,6 +1239,7 @@ impl Render for Graphic {
Graphic::ColorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::GradientList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::TextList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::StrokeList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
}
}
@@ -1836,7 +1849,8 @@ fn render_vector_item_to_vello(
| Graphic::RasterCPUList(_)
| Graphic::RasterGPUList(_)
| Graphic::GraphicList(_)
| Graphic::TextList(_) => {
| Graphic::TextList(_)
| Graphic::StrokeList(_) => {
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
paint.render_to_vello(scene, multiplied_transform, context, paint_render_params);
scene.pop_layer();
@@ -1928,7 +1942,8 @@ fn render_vector_item_to_vello(
| Graphic::RasterCPUList(_)
| Graphic::RasterGPUList(_)
| Graphic::GraphicList(_)
| Graphic::TextList(_) => {
| Graphic::TextList(_)
| Graphic::StrokeList(_) => {
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);
@@ -2557,6 +2572,12 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster<GPU>>, scene: &mut S
}
}
impl Render for List<brush_types::Stroke> {
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).

View File

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

View File

@@ -1,7 +1,33 @@
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 use brush_types::*;
#[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>,
) -> List<Graphic> {
let (diameter, hardness, flow) = (diameter.into_element(), hardness.into_element(), flow.into_element());
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;

View File

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

View File

@@ -1,3 +1,4 @@
use brush_types::Stroke;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath};
use core_types::registry::types::{Angle, SeedValue, SignedInteger};
@@ -870,6 +871,7 @@ pub async fn extend<T: 'n + Send + Clone>(
List<Color>,
List<Gradient>,
List<Artboard>,
List<Stroke>,
)]
base: List<T>,
/// The list whose items will appear at the end of the extended list.
@@ -890,6 +892,7 @@ pub async fn extend<T: 'n + Send + Clone>(
List<Color>,
List<Gradient>,
List<Artboard>,
List<Stroke>,
)]
new: List<T>,
) -> List<T> {
@@ -942,6 +945,7 @@ pub async fn into_group<T: Into<Graphic> + 'n>(
List<String>,
List<DVec2>,
Item<DAffine2>, // TODO: Remove this
List<Stroke>,
)]
content: T,
) -> Item<Graphic> {

View File

@@ -268,6 +268,8 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
// Rasters, colors, and gradients bound no region, so they contribute no operand
Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) => Vec::new(),
Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) => Vec::new(),
// Strokes have no vector outline representation; a brush node renders them to rasters
Graphic::StrokeList(_) => Vec::new(),
// Normalized to GraphicList above
Graphic::Graphic(_) => Vec::new(),
}