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

@@ -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).