Add Table<GradientStops> gradient rendering (#3989)

* Add Table<GradientStops> gradient rendering

* Add SVG and Vello renderers for Table<GradientStops>

* Add thumbnail rendering for Table<GradientStops>

* Use row transform to map (0,0), (1,0) unit line to document space

* Set 100px width for the initially created gradient

* Add support of table gradients for the gradient tool

* Fix after review

* Thumbnail rendering of artboard with infinite gradient layer

* Hide radial gradient's reverse direction button for gradient table

* Remove unused imports

* Format

* Fix conflict with spread method

* Code review

* Fix thumbnails

* Connect up gradient_type and spread_method to attributes

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
YohYamasaki
2026-04-29 14:45:50 +02:00
committed by GitHub
parent 8cadafa063
commit e686ee9f42
19 changed files with 615 additions and 92 deletions

View File

@@ -3,15 +3,16 @@ use crate::application_io::PlatformEditorApi;
use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_cache::BrushCache;
use brush_nodes::brush_stroke::BrushStroke;
use core_types::table::Table;
use core_types::table::{Table, TableRow};
use core_types::transform::Footprint;
use core_types::uuid::NodeId;
use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type};
use core_types::{ATTR_TRANSFORM, CacheHash, Color, ContextFeatures, MemoHash, Node, Type};
use dyn_any::DynAny;
pub use dyn_any::StaticType;
use glam::{Affine2, Vec2};
pub use glam::{DAffine2, DVec2, IVec2, UVec2};
use graphic_types::raster_types::{CPU, Image, Raster};
use graphic_types::vector_types::gradient::GRADIENT_TABLE_DEFAULT_SCALE;
use graphic_types::vector_types::vector::style::{Fill, Gradient, GradientStops, Stroke};
use graphic_types::vector_types::vector::{self, ReferencePoint};
use graphic_types::{Graphic, Vector};
@@ -118,7 +119,9 @@ macro_rules! tagged_value {
x if x == TypeId::of::<()>() => TaggedValue::None,
// Table-wrapped types need a single-item default with the element's default, not an empty table
x if x == TypeId::of::<Table<Color>>() => TaggedValue::Color(Table::new_from_element(Color::default())),
x if x == TypeId::of::<Table<GradientStops>>() => TaggedValue::GradientTable(Table::new_from_element(GradientStops::default())),
x if x == TypeId::of::<Table<GradientStops>>() => TaggedValue::GradientTable(Table::new_from_row(
TableRow::new_from_element(GradientStops::default()).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(GRADIENT_TABLE_DEFAULT_SCALE))),
)),
$( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )*
_ => return None,
})

View File

@@ -11,6 +11,15 @@ pub enum RenderBoundingBox {
pub trait BoundingBox {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox;
/// Returns the bounding box to use when sizing this value's thumbnail in the Layers panel.
///
/// Diverges from `bounding_box` for types where the rendering bounds wouldn't make a useful thumbnail frame.
/// For instance, `GradientStops` is `Infinite` for rendering but returns the line's AABB here, so a `Table<Graphic>`
/// group of a gradient and a vector frames around the vector's geometry rather than infinity.
/// Types with no meaningful contribution (e.g., `Color`) return `Infinite` from both; the runtime substitutes a
/// small fallback rectangle at the end if no finite bounds remain after combining.
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox;
}
macro_rules! none_impl {
@@ -19,6 +28,10 @@ macro_rules! none_impl {
fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
RenderBoundingBox::None
}
fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
RenderBoundingBox::None
}
}
};
}
@@ -32,4 +45,9 @@ impl BoundingBox for Color {
fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
RenderBoundingBox::Infinite
}
fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
// A solid color has no intrinsic extent, so its container's other content frames the thumbnail
RenderBoundingBox::Infinite
}
}

View File

@@ -34,7 +34,8 @@ use std::any::TypeId;
use std::future::Future;
use std::pin::Pin;
pub use table::{
ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_LOCATION, ATTR_NAME, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_SPREAD_METHOD,
ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
};
#[cfg(feature = "wasm")]
pub use tsify;

View File

@@ -57,6 +57,14 @@ pub const ATTR_BACKGROUND: &str = "background";
/// Attribute key for an artboard row's `bool` flag indicating whether content is clipped to the artboard bounds.
pub const ATTR_CLIP: &str = "clip";
/// Attribute key for a `Table<GradientStops>` row's `GradientSpreadMethod`, controlling the gradient's behavior
/// outside the start/end stops (`Pad` clamps to the boundary colors, `Reflect` mirrors, `Repeat` tiles).
pub const ATTR_SPREAD_METHOD: &str = "spread_method";
/// Attribute key for a `Table<GradientStops>` row's `GradientType`, choosing between a linear gradient (color
/// transitions along the gradient line) or a radial gradient (color transitions outward from the line's start).
pub const ATTR_GRADIENT_TYPE: &str = "gradient_type";
// =====================
// TRAIT: AttributeValue
// =====================
@@ -824,12 +832,12 @@ impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Table<T> {
}
impl<T: BoundingBox> BoundingBox for Table<T> {
/// Computes the combined bounding box of all rows, composing each row's transform attribute with the given transform.
/// Computes the combined bounding box of all items, composing each item's transform attribute with the given transform.
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
let mut combined_bounds = None;
for (element, row_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<DAffine2>(ATTR_TRANSFORM)) {
match element.bounding_box(transform * row_transform, include_stroke) {
for (element, item_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<DAffine2>(ATTR_TRANSFORM)) {
match element.bounding_box(transform * item_transform, include_stroke) {
RenderBoundingBox::None => continue,
RenderBoundingBox::Infinite => return RenderBoundingBox::Infinite,
RenderBoundingBox::Rectangle(bounds) => match combined_bounds {
@@ -844,6 +852,29 @@ impl<T: BoundingBox> BoundingBox for Table<T> {
None => RenderBoundingBox::None,
}
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
// `Infinite` items are skipped here (rather than propagating outward as in `bounding_box`) so a finite sibling in a mixed group dictates the framing
let mut combined_bounds = None;
let mut any_infinite = false;
for (element, item_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<DAffine2>(ATTR_TRANSFORM)) {
match element.thumbnail_bounding_box(transform * item_transform, include_stroke) {
RenderBoundingBox::None => continue,
RenderBoundingBox::Infinite => any_infinite = true,
RenderBoundingBox::Rectangle(bounds) => match combined_bounds {
Some(existing) => combined_bounds = Some(Quad::combine_bounds(existing, bounds)),
None => combined_bounds = Some(bounds),
},
}
}
match (combined_bounds, any_infinite) {
(Some(bounds), _) => RenderBoundingBox::Rectangle(bounds),
(None, true) => RenderBoundingBox::Infinite,
(None, false) => RenderBoundingBox::None,
}
}
}
impl<T> IntoIterator for Table<T> {
@@ -897,14 +928,14 @@ impl<T: PartialEq> PartialEq for Table<T> {
}
impl<T> ApplyTransform for Table<T> {
/// Right-multiplies the modification into each row's transform attribute.
/// Right-multiplies the modification into each item's transform attribute.
fn apply_transform(&mut self, modification: &DAffine2) {
for transform in self.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*transform *= *modification;
}
}
/// Left-multiplies the modification into each row's transform attribute.
/// Left-multiplies the modification into each item's transform attribute.
fn left_apply_transform(&mut self, modification: &DAffine2) {
for transform in self.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*transform = *modification * *transform;

View File

@@ -357,6 +357,17 @@ impl BoundingBox for Graphic {
Graphic::Gradient(table) => table.bounding_box(transform, include_stroke),
}
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self {
Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke),
Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke),
}
}
}
impl TableConvert<Graphic> for Vector {

View File

@@ -227,6 +227,10 @@ where
let unit_rectangle = Quad::from_box([DVec2::ZERO, DVec2::ONE]);
RenderBoundingBox::Rectangle((transform * unit_rectangle).bounding_box())
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
self.bounding_box(transform, include_stroke)
}
}
// RenderComplexity trait implementations

View File

@@ -26,6 +26,7 @@ kurbo = { workspace = true }
vector-types = { workspace = true }
graphic-types = { workspace = true }
vello = { workspace = true }
vello_encoding = { workspace = true }
# Optional workspace dependencies
serde = { workspace = true, optional = true }

View File

@@ -10,7 +10,9 @@ use core_types::render_complexity::RenderComplexity;
use core_types::table::{Table, TableRow};
use core_types::transform::Footprint;
use core_types::uuid::{NodeId, generate_uuid};
use core_types::{ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_LOCATION, ATTR_TRANSFORM};
use core_types::{
ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_SPREAD_METHOD, ATTR_TRANSFORM,
};
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use graphene_hash::CacheHashWrapper;
@@ -287,6 +289,10 @@ pub fn to_transform(transform: DAffine2) -> usvg::Transform {
usvg::Transform::from_row(cols[0] as f32, cols[1] as f32, cols[2] as f32, cols[3] as f32, cols[4] as f32, cols[5] as f32)
}
fn to_point(p: DVec2) -> kurbo::Point {
kurbo::Point::new(p.x, p.y)
}
fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::Color) {
use core_types::consts::LAYER_OUTLINE_STROKE_WEIGHT;
@@ -1088,7 +1094,6 @@ impl Render for Table<Vector> {
}
let layer_bounds = element.bounding_box().unwrap_or_default();
let to_point = |p: DVec2| kurbo::Point::new(p.x, p.y);
let mut path = kurbo::BezPath::new();
for mut bezpath in element.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
@@ -1749,16 +1754,35 @@ impl Render for Table<Color> {
}
impl Render for Table<GradientStops> {
// TODO: Fix infinite gradient rendering
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
// For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`.
// The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million.
let thumbnail_rect = if render_params.thumbnail {
let truncated_size = render_params.footprint.resolution.as_dvec2();
let margin = DVec2::ONE;
Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin))
} else {
None
};
for index in 0..self.len() {
let Some(gradient) = self.element(index) else { continue };
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let alpha_blending: AlphaBlending = self.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, index);
render.leaf_tag("rect", |attributes| {
// Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead
let max = u64::MAX;
attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}"));
let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, index);
let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, index);
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
render.leaf_tag(tag, |attributes| {
if let Some((min, size)) = thumbnail_rect {
attributes.push("x", min.x.to_string());
attributes.push("y", min.y.to_string());
attributes.push("width", size.x.to_string());
attributes.push("height", size.y.to_string());
} else {
// Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead
let max = u64::MAX;
attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}"));
}
let mut stop_string = String::new();
for (position, color, original_midpoint) in gradient.interpolated_samples() {
@@ -1772,7 +1796,8 @@ impl Render for Table<GradientStops> {
stop_string.push_str(" />");
}
let gradient_transform = render_params.footprint.transform * transform;
// render_thumbnail already added the footprint transform
let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform };
let gradient_transform_matrix = format_transform_matrix(gradient_transform);
let gradient_transform_attribute = if gradient_transform_matrix.is_empty() {
String::new()
@@ -1781,24 +1806,24 @@ impl Render for Table<GradientStops> {
};
let gradient_id = generate_uuid();
let start = DVec2::ZERO;
let end = DVec2::X;
let spread_method_attribute = if spread_method == GradientSpreadMethod::Pad {
String::new()
} else {
format!(r#" spreadMethod="{}""#, spread_method.svg_name())
};
match GradientType::Radial {
// The unit gradient line is the +X unit vector in local space, before the item's transform is applied
match gradient_type {
GradientType::Linear => {
let (x1, y1) = (start.x, start.y);
let (x2, y2) = (end.x, end.y);
let _ = write!(
&mut attributes.0.svg_defs,
r#"<linearGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"{gradient_transform_attribute}>{stop_string}</linearGradient>"#
r#"<linearGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"{spread_method_attribute}{gradient_transform_attribute}>{stop_string}</linearGradient>"#
);
}
GradientType::Radial => {
let (cx, cy) = (start.x, start.y);
let r = start.distance(end);
let _ = write!(
&mut attributes.0.svg_defs,
r#"<radialGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" cx="{cx}" cy="{cy}" r="{r}"{gradient_transform_attribute}>{stop_string}</radialGradient>"#
r#"<radialGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"{spread_method_attribute}{gradient_transform_attribute}>{stop_string}</radialGradient>"#
);
}
}
@@ -1817,28 +1842,82 @@ impl Render for Table<GradientStops> {
}
}
// TODO: Fix infinite gradient rendering
fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
use vello::peniko;
for (gradient, alpha_blending) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<AlphaBlending>(ATTR_ALPHA_BLENDING)) {
if let RenderMode::Outline = render_params.render_mode {
return;
}
for ((((gradient, transform), alpha_blending), spread_method), gradient_type) in self
.iter_element_values()
.zip(self.iter_attribute_values_or_default::<DAffine2>(ATTR_TRANSFORM))
.zip(self.iter_attribute_values_or_default::<AlphaBlending>(ATTR_ALPHA_BLENDING))
.zip(self.iter_attribute_values_or_default::<GradientSpreadMethod>(ATTR_SPREAD_METHOD))
.zip(self.iter_attribute_values_or_default::<GradientType>(ATTR_GRADIENT_TYPE))
{
let gradient_transform = parent_transform * transform;
let blend_mode = alpha_blending.blend_mode.to_peniko();
let opacity = alpha_blending.opacity(render_params.for_mask);
let color = gradient.color.first().copied().unwrap_or(Color::MAGENTA);
let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]);
let mut stops: peniko::ColorStops = peniko::ColorStops::new();
for (position, color, _) in gradient.interpolated_samples() {
stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
})
}
let extend = match spread_method {
GradientSpreadMethod::Pad => peniko::Extend::Pad,
GradientSpreadMethod::Reflect => peniko::Extend::Reflect,
GradientSpreadMethod::Repeat => peniko::Extend::Repeat,
};
// The unit gradient line is the +X unit vector in local space, before the item's transform is applied.
// For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies.
let kind = match gradient_type {
GradientType::Linear => peniko::LinearGradientPosition {
start: to_point(DVec2::ZERO),
end: to_point(DVec2::X),
}
.into(),
GradientType::Radial => peniko::RadialGradientPosition {
start_center: to_point(DVec2::ZERO),
start_radius: 0.,
end_center: to_point(DVec2::ZERO),
end_radius: 1.,
}
.into(),
};
let fill = peniko::Brush::Gradient(peniko::Gradient {
kind,
stops,
extend,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
..Default::default()
});
let brush_transform = kurbo::Affine::new((gradient_transform).to_cols_array());
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
let mut layer = false;
if opacity < 1. || alpha_blending.blend_mode != BlendMode::default() {
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
// See implemenation in `Table<Color>` for more detail
// See implementation in `Table<Color>` for more detail
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect);
layer = true;
}
scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect);
// Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform
scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY)));
scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero);
scene.encoding_mut().encode_shape(&rect, true);
scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform));
scene.encoding_mut().swap_last_path_tags();
scene.encoding_mut().encode_brush(&fill, 1.);
if layer {
scene.pop_layer();

View File

@@ -2,6 +2,10 @@ use core_types::{Color, render_complexity::RenderComplexity};
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
/// Default scale applied to a freshly-created `Table<GradientStops>` item's transform.
/// Places the unit gradient line (the +X unit vector in local space) inside a 100×100 document-space box.
pub const GRADIENT_TABLE_DEFAULT_SCALE: f64 = 100.;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -488,4 +492,12 @@ impl core_types::bounds::BoundingBox for GradientStops {
fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox {
core_types::bounds::RenderBoundingBox::Infinite
}
fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox {
// AABB of the gradient line itself, leaving aspect padding and sub-pixel fallbacks to the runtime so this stays
// a clean per-item geometric bound that combines naturally with siblings
let start = transform.transform_point2(DVec2::ZERO);
let end = transform.transform_point2(DVec2::X);
core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)])
}
}

View File

@@ -468,6 +468,10 @@ impl BoundingBox for Vector {
None => RenderBoundingBox::None,
}
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
BoundingBox::bounding_box(self, transform, include_stroke)
}
}
impl RenderComplexity for Vector {