mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
23
node-graph/libraries/brush-types/Cargo.toml
Normal file
23
node-graph/libraries/brush-types/Cargo.toml
Normal 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 }
|
||||
131
node-graph/libraries/brush-types/src/lib.rs
Normal file
131
node-graph/libraries/brush-types/src/lib.rs
Normal 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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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. };
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user