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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 615 additions and 92 deletions

1
Cargo.lock generated
View File

@ -4441,6 +4441,7 @@ dependencies = [
"usvg",
"vector-types",
"vello",
"vello_encoding",
]
[[package]]

View File

@ -1,3 +1,7 @@
// Bumped past the default 128 because the deeply-generic message-passing types pull in wgpu/naga
// trait chains that overflow the trait resolver under `--tests`. Set to the same value the compiler suggests.
#![recursion_limit = "256"]
extern crate graphite_proc_macros;
// `macro_use` puts these macros into scope for all descendant code files

View File

@ -9,10 +9,10 @@ use graphene_std::color::Color;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath;
use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::PointId;
use graphene_std::vector::VectorModificationType;
use graphene_std::vector::style::{Fill, Stroke};
use graphene_std::vector::{GradientStops, PointId, VectorModificationType};
#[impl_message(Message, DocumentMessage, GraphOperation)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -25,6 +25,10 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier,
fill: f64,
},
GradientTableSet {
layer: LayerNodeIdentifier,
gradient_table: Table<GradientStops>,
},
OpacitySet {
layer: LayerNodeIdentifier,
opacity: f64,

View File

@ -45,6 +45,11 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.blending_fill_set(fill);
}
}
GraphOperationMessage::GradientTableSet { layer, gradient_table } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_table_set(gradient_table);
}
}
GraphOperationMessage::OpacitySet { layer, opacity } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.opacity_set(opacity);

View File

@ -13,9 +13,8 @@ use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath;
use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::Vector;
use graphene_std::vector::style::{Fill, Stroke};
use graphene_std::vector::{PointId, VectorModification, VectorModificationType};
use graphene_std::vector::{GradientStops, PointId, Vector, VectorModification, VectorModificationType};
use graphene_std::{Color, Graphic, NodeInputDecleration};
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
@ -461,6 +460,15 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false);
}
pub fn gradient_table_set(&mut self, gradient_table: Table<GradientStops>) {
let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else {
return;
};
let input_connector = InputConnector::node(gradient_node_id, graphene_std::math_nodes::gradient_value::GradientInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientTable(gradient_table), false), false);
}
pub fn clip_mode_toggle(&mut self, clip_mode: Option<bool>) {
let clip = !clip_mode.unwrap_or(false);
let Some(clip_node_id) = self.existing_proto_node_id(graphene_std::blending_nodes::blending::IDENTIFIER, true) else {

View File

@ -13,6 +13,7 @@ use glam::{DAffine2, DVec2};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::{Type, concrete};
use graphene_std::ATTR_TRANSFORM;
use graphene_std::NodeInputDecleration;
use graphene_std::animation::RealTimeMode;
use graphene_std::extract_xy::XY;
@ -1154,20 +1155,33 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
.on_commit(commit_value)
.widget_instance(),
),
TaggedValue::GradientTable(gradient_table) => widgets.push(
TaggedValue::GradientTable(gradient_table) => {
let existing_transform: DAffine2 = gradient_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
widgets.push(
color_button
.value(match gradient_table.element(0) {
Some(gradient) => FillChoice::Gradient(gradient.clone()),
None => FillChoice::Gradient(GradientStops::default()),
})
.on_update(update_value(
|input: &ColorInput| TaggedValue::GradientTable(input.value.as_gradient().iter().map(|&gradient| TableRow::new_from_element(gradient.clone())).collect()),
move |input: &ColorInput| {
TaggedValue::GradientTable(
input
.value
.as_gradient()
.iter()
.map(|&gradient| TableRow::new_from_element(gradient.clone()).with_attribute(ATTR_TRANSFORM, existing_transform))
.collect(),
)
},
node_id,
index,
))
.on_commit(commit_value)
.widget_instance(),
),
)
}
x => warn!("Color {x:?}"),
}

View File

@ -16,7 +16,7 @@ use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::misc::ManipulatorPointId;
use graphene_std::vector::style::{Fill, Gradient};
use graphene_std::vector::{PointId, SegmentId, VectorModificationType};
use graphene_std::vector::{GradientStops, PointId, SegmentId, VectorModificationType};
use std::collections::VecDeque;
/// Returns the ID of the first Spline node in the horizontal flow which is not followed by a `Path` node, or `None` if none exists.
@ -280,6 +280,15 @@ pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI
Some(gradient.clone())
}
/// Get the gradient table of a layer.
pub fn get_gradient_table(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Table<GradientStops>> {
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER))?;
let TaggedValue::GradientTable(gradient_table) = inputs.get(graphene_std::math_nodes::gradient_value::GradientInput::INDEX)?.as_value()? else {
return None;
};
Some(gradient_table.clone())
}
/// Get the current fill of a layer from the closest "Fill" node.
pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Color> {
let fill_index = 1;

View File

@ -3,13 +3,17 @@ use crate::consts::{
COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, GRADIENT_MIDPOINT_DIAMOND_RADIUS, GRADIENT_MIDPOINT_MAX, GRADIENT_MIDPOINT_MIN, GRADIENT_STOP_MIN_VIEWPORT_GAP, LINE_ROTATE_SNAP_ANGLE,
MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD,
};
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_gradient};
use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_table, is_layer_fed_by_node_of_name};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use graphene_std::raster::color::Color;
use graphene_std::table::{Table, TableRow};
use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType};
use graphene_std::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM};
#[derive(Default, ExtractField)]
pub struct GradientTool {
@ -131,7 +135,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
}
// Sync tool options with the selected layer's gradient
if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(&context.document) {
if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(context.document) {
let type_differs = self.options.gradient_type != gradient.gradient_type;
let spread_method_differs = self.options.spread_method != gradient.spread_method;
@ -161,6 +165,8 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
impl LayoutHolder for GradientTool {
fn layout(&self) -> Layout {
let mut widgets: Vec<WidgetInstance> = Vec::new();
let gradient_type = RadioInput::new(vec![
RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| {
GradientToolMessage::UpdateOptions {
@ -178,6 +184,8 @@ impl LayoutHolder for GradientTool {
.selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32))
.widget_instance();
widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]);
let reverse_stops = IconButton::new("Reverse", 24)
.tooltip_label("Reverse Stops")
.tooltip_description("Reverse the gradient color stops.")
@ -191,19 +199,19 @@ impl LayoutHolder for GradientTool {
.widget_instance();
let spread_method = RadioInput::new(vec![
RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad").on_update(move |_| {
RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad Spread Method").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad),
}
.into()
}),
RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect").on_update(move |_| {
RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect Spread Method").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect),
}
.into()
}),
RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat").on_update(move |_| {
RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat Spread Method").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat),
}
@ -213,13 +221,7 @@ impl LayoutHolder for GradientTool {
.selected_index(Some(self.options.spread_method as u32))
.widget_instance();
let mut widgets = vec![
gradient_type,
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
spread_method,
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
reverse_stops,
];
widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]);
if self.options.gradient_type == GradientType::Radial {
let orientation = self
@ -273,14 +275,63 @@ impl Default for GradientToolFsmState {
/// Computes the transform from gradient space to viewport space (where gradient space is 0..1)
fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 {
let bounds = document.metadata().nonzero_bounding_box(layer);
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
// TODO: Drop the `is_gradient_table` branch once all gradients are `Table<GradientStops>`
let is_gradient_table = is_layer_fed_by_node_of_name(
layer,
&document.network_interface,
&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER),
);
if is_gradient_table {
// Table<GradientStops> layers use the item's transform from gradient space to document space,
// so we cannot use `transform_to_viewport` here as it would apply the transform twice.
return document
.metadata()
.upstream_footprints
.get(&layer.to_node())
.map(|footprint| footprint.transform)
.unwrap_or(document.metadata().document_to_viewport);
}
let multiplied = document.metadata().transform_to_viewport(layer);
let bounds = document.metadata().nonzero_bounding_box(layer);
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
multiplied * bound_transform
}
/// Builds the item transform that maps the unit gradient line (the +X unit vector in local space) to
/// the segment from `start` to `end` in document space. The perpendicular column is forced to the same magnitude
/// as the `start`..`end` direction so the matrix stays invertible (linear gradients ignore the perpendicular axis,
/// but click detection uses the full inverse).
// TODO: Apply a separate scale on the perpendicular axis when we support elliptical gradients
fn gradient_item_transform(start: DVec2, end: DVec2) -> DAffine2 {
let delta = end - start;
let perp = DVec2::new(-delta.y, delta.x);
DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, start.x, start.y])
}
// TODO: Remove this whole function once all gradients are `Table<GradientStops>`
fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Gradient> {
match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) {
(Some(gradient_graphic), _) => {
let stops = gradient_graphic.element(0)?.clone();
let transform: DAffine2 = gradient_graphic.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let spread_method: GradientSpreadMethod = gradient_graphic.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0);
let gradient_type: GradientType = gradient_graphic.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0);
let gradient = Gradient {
stops,
gradient_type,
spread_method,
start: transform.transform_point2(DVec2::ZERO),
end: transform.transform_point2(DVec2::X),
};
Some(gradient)
}
(None, Some(gradient)) => Some(gradient),
(None, None) => None,
}
}
/// Whether two adjacent stops are too closely packed in viewport space for a midpoint diamond to be shown or interacted with.
fn midpoint_hidden_by_proximity(left_stop_pos: f64, right_stop_pos: f64, viewport_line_length: f64) -> bool {
(right_stop_pos - left_stop_pos) * viewport_line_length < GRADIENT_STOP_MIN_VIEWPORT_GAP * 2.
@ -304,6 +355,8 @@ struct SelectedGradient {
gradient: Gradient,
dragging: GradientDragTarget,
initial_gradient: Gradient,
// TODO: Remove (and the matching branches in `render_gradient` / pointer-up) once `Table<GradientStops>` replaces legacy `Fill::Gradient`
is_gradient_table: bool,
}
fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: DVec2) -> Option<f64> {
@ -347,12 +400,14 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D
impl SelectedGradient {
pub fn new(gradient: Gradient, layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Self {
let transform = gradient_space_transform(layer, document);
let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some();
Self {
layer: Some(layer),
transform,
gradient: gradient.clone(),
dragging: GradientDragTarget::End,
initial_gradient: gradient,
is_gradient_table,
}
}
@ -568,12 +623,23 @@ impl SelectedGradient {
/// Update the layer fill to the current gradient
pub fn render_gradient(&mut self, responses: &mut VecDeque<Message>) {
if let Some(layer) = self.layer {
// TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>`
if self.is_gradient_table {
let gradient_table = Table::new_from_row(
TableRow::new_from_element(self.gradient.stops.clone())
.with_attribute(ATTR_TRANSFORM, gradient_item_transform(self.gradient.start, self.gradient.end))
.with_attribute(ATTR_SPREAD_METHOD, self.gradient.spread_method)
.with_attribute(ATTR_GRADIENT_TYPE, self.gradient.gradient_type),
);
responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table });
} else {
responses.add(GraphOperationMessage::FillSet {
layer,
fill: Fill::Gradient(self.gradient.clone()),
});
}
}
}
}
impl GradientTool {
@ -952,8 +1018,11 @@ impl Fsm for GradientToolFsmState {
};
// The gradient has only one point and so should become a fill
// TODO: Drop the legacy `Fill::Solid` branch when all gradients become `Table<GradientStops>`
if selected_gradient.gradient.stops.len() == 1 {
if let Some(layer) = selected_gradient.layer {
if selected_gradient.is_gradient_table {
selected_gradient.render_gradient(responses);
} else if let Some(layer) = selected_gradient.layer {
responses.add(GraphOperationMessage::FillSet {
layer,
fill: Fill::Solid(selected_gradient.gradient.stops.color[0]),
@ -1047,6 +1116,7 @@ impl Fsm for GradientToolFsmState {
for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) {
let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue };
let transform = gradient_space_transform(layer, document);
let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some();
// Check for dragging a midpoint diamond
if drag_hint.is_none() {
@ -1074,6 +1144,7 @@ impl Fsm for GradientToolFsmState {
gradient: gradient.clone(),
dragging: GradientDragTarget::Midpoint(i),
initial_gradient: gradient.clone(),
is_gradient_table,
});
break;
@ -1114,6 +1185,7 @@ impl Fsm for GradientToolFsmState {
gradient: gradient.clone(),
dragging: drag_target,
initial_gradient: gradient.clone(),
is_gradient_table,
});
}
}
@ -1130,6 +1202,7 @@ impl Fsm for GradientToolFsmState {
gradient: gradient.clone(),
dragging: dragging_target,
initial_gradient: gradient.clone(),
is_gradient_table,
})
}
}
@ -1538,12 +1611,26 @@ fn apply_gradient_update(
transaction_started = true;
}
update(&mut gradient);
// Only check for the gradient table once we know we'll write back, since this is a graph traversal per layer
// TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>`
if get_gradient_table(layer, &context.document.network_interface).is_some() {
// Rebuild the item transform from the (possibly mutated) start/end so updates like `ReverseDirection` that only swap endpoints are reflected in the stored attribute
let gradient_table = Table::new_from_row(
TableRow::new_from_element(gradient.stops.clone())
.with_attribute(ATTR_TRANSFORM, gradient_item_transform(gradient.start, gradient.end))
.with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method)
.with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type),
);
responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table });
} else {
responses.add(GraphOperationMessage::FillSet {
layer,
fill: Fill::Gradient(gradient),
});
}
}
}
if transaction_started {
responses.add(DocumentMessage::EndTransaction);
@ -1623,11 +1710,14 @@ mod test_gradient {
use crate::messages::input_mapper::utility_types::input_mouse::ScrollDelta;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector};
pub use crate::test_utils::test_prelude::*;
use glam::DAffine2;
use graphene_std::vector::fill;
use graphene_std::vector::style::Fill;
use graphene_std::vector::style::Gradient;
use graph_craft::document::value::TaggedValue;
use graphene_std::ATTR_TRANSFORM;
use graphene_std::table::{Table, TableRow};
use graphene_std::vector::style::{Fill, Gradient};
use graphene_std::vector::{GradientStop, GradientStops, fill};
use super::gradient_space_transform;
@ -1672,6 +1762,45 @@ mod test_gradient {
}
}
async fn create_gradient_table_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier {
editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await;
let document = editor.active_document();
let layer = document.metadata().all_layers().next().unwrap();
let gradient_node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)).await;
editor
.handle_message(NodeGraphMessage::CreateWire {
output_connector: OutputConnector::node(gradient_node_id, 0),
input_connector: InputConnector::node(layer.to_node(), 1),
})
.await;
editor
.handle_message(NodeGraphMessage::SetInputValue {
node_id: gradient_node_id,
input_index: 1,
value: TaggedValue::GradientTable(Table::new_from_row(
TableRow::new_from_element(GradientStops::new([
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::RED,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLUE,
},
]))
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY),
)),
})
.await;
layer
}
#[tokio::test]
async fn ignore_artboard() {
let mut editor = EditorTestUtils::create();
@ -2037,4 +2166,146 @@ mod test_gradient {
let (gradient, _) = get_gradient(&mut editor).await;
assert_eq!(gradient.spread_method, GradientSpreadMethod::Reflect);
}
#[tokio::test]
async fn gradient_table_drag_endpoint() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
let layer = create_gradient_table_layer(&mut editor).await;
// Create original transform for the control geometry and apply it
let initial_start = DVec2::new(10., 50.);
let initial_end = DVec2::new(200., 50.);
let initial_item_transform = super::gradient_item_transform(initial_start, initial_end);
editor
.handle_message(GraphOperationMessage::GradientTableSet {
layer,
gradient_table: Table::new_from_row(
TableRow::new_from_element(GradientStops::new([
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::RED,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLUE,
},
]))
.with_attribute(ATTR_TRANSFORM, initial_item_transform),
),
})
.await;
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await;
let document = editor.active_document();
let space_transform = gradient_space_transform(layer, document);
let gradient = super::get_gradient(layer, &document.network_interface).unwrap();
let viewport_start = space_transform.transform_point2(gradient.start);
let viewport_end = space_transform.transform_point2(gradient.end);
// Drag target of the end point, move 80px down
let new_viewport_end = viewport_end + DVec2::new(0., 80.);
editor.select_tool(ToolType::Gradient).await;
editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await;
editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await;
editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await;
editor
.mouseup(
EditorMouseState {
editor_position: new_viewport_end,
mouse_keys: MouseKeys::empty(),
scroll_delta: ScrollDelta::default(),
},
ModifierKeys::empty(),
)
.await;
// Verify if the gradient position is updated correctly
let document = editor.active_document();
let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag");
let updated_space_transform = gradient_space_transform(layer, document);
let updated_viewport_start = updated_space_transform.transform_point2(updated.start);
let updated_viewport_end = updated_space_transform.transform_point2(updated.end);
assert!(
updated_viewport_start.abs_diff_eq(viewport_start, 1.),
"Start should not move. Expected {viewport_start:?}, got {updated_viewport_start:?}"
);
assert!(
updated_viewport_end.abs_diff_eq(new_viewport_end, 1.),
"End should move to new position. Expected {new_viewport_end:?}, got {updated_viewport_end:?}"
);
}
#[tokio::test]
async fn gradient_table_preserves_stops() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
let layer = create_gradient_table_layer(&mut editor).await;
// Set up a 3-stop gradient with distinct colors
let original_stops = GradientStops::new([
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::RED,
},
GradientStop {
position: 0.5,
midpoint: 0.5,
color: Color::GREEN,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLUE,
},
]);
let initial_start = DVec2::new(10., 50.);
let initial_end = DVec2::new(200., 50.);
let initial_item_transform = super::gradient_item_transform(initial_start, initial_end);
editor
.handle_message(GraphOperationMessage::GradientTableSet {
layer,
gradient_table: Table::new_from_row(TableRow::new_from_element(original_stops.clone()).with_attribute(ATTR_TRANSFORM, initial_item_transform)),
})
.await;
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await;
let document = editor.active_document();
let space_transform = gradient_space_transform(layer, document);
let gradient = super::get_gradient(layer, &document.network_interface).unwrap();
let viewport_end = space_transform.transform_point2(gradient.end);
// Drag the end point 80px down
let new_viewport_end = viewport_end + DVec2::new(0., 80.);
editor.select_tool(ToolType::Gradient).await;
editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await;
editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await;
editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await;
editor
.mouseup(
EditorMouseState {
editor_position: new_viewport_end,
mouse_keys: MouseKeys::empty(),
scroll_delta: ScrollDelta::default(),
},
ModifierKeys::empty(),
)
.await;
// Verify stops are preserved after dragging
let document = editor.active_document();
let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag");
assert_eq!(updated.stops.len(), 3, "Stop count should be preserved");
assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10);
assert_eq!(updated.stops.color[0].to_rgba8_srgb(), Color::RED.to_rgba8_srgb(), "First stop color should be preserved");
assert_eq!(updated.stops.color[1].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb(), "Middle stop color should be preserved");
assert_eq!(updated.stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb(), "Last stop color should be preserved");
}
}

View File

@ -1,6 +1,6 @@
use super::*;
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
use glam::{DAffine2, UVec2};
use glam::{DAffine2, DVec2, UVec2};
use graph_craft::application_io::{PlatformApplicationIo, PlatformEditorApi};
use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue};
use graph_craft::document::{NodeId, NodeNetwork};
@ -8,7 +8,7 @@ use graph_craft::graphene_compiler::Compiler;
use graph_craft::proto::GraphErrors;
use graph_craft::{ProtoNodeIdentifier, concrete};
use graphene_std::application_io::{ApplicationIo, ExportFormat, ImageTexture, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig};
use graphene_std::bounds::RenderBoundingBox;
use graphene_std::bounds::{BoundingBox, RenderBoundingBox};
use graphene_std::memo::IORecord;
use graphene_std::ops::Convert;
#[cfg(all(target_family = "wasm", feature = "gpu", feature = "wasm"))]
@ -435,13 +435,16 @@ impl NodeRuntime {
// Graphic table: thumbnail
if let Some(io) = introspected_data.downcast_ref::<IORecord<Context, Table<Graphic>>>() {
if update_thumbnails {
Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, responses)
let bounds = io.output.thumbnail_bounding_box(DAffine2::IDENTITY, true);
Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, bounds, responses)
}
}
// Artboard table: thumbnail
// Artboard thumbnail bounds come from the clipping rectangles, not the content union, since the renderer
// clips content to those rectangles so anything outside isn't visible
else if let Some(io) = introspected_data.downcast_ref::<IORecord<Context, Table<Table<Graphic>>>>() {
if update_thumbnails {
Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, responses)
let bounds = artboard_clip_bounds(&io.output);
Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, bounds, responses)
}
}
// Vector table: vector modifications
@ -457,7 +460,13 @@ impl NodeRuntime {
}
/// If this is `Graphic` data, regenerate click targets and thumbnails for the layers in the graph, modifying the state and updating the UI.
fn render_thumbnail(thumbnail_renders: &mut HashMap<NodeId, Vec<SvgSegment>>, parent_network_node_id: NodeId, graphic: &impl Render, responses: &mut VecDeque<FrontendMessage>) {
fn render_thumbnail(
thumbnail_renders: &mut HashMap<NodeId, Vec<SvgSegment>>,
parent_network_node_id: NodeId,
graphic: &impl Render,
bounds: RenderBoundingBox,
responses: &mut VecDeque<FrontendMessage>,
) {
// Skip thumbnails if the layer is too complex (for performance)
if graphic.render_complexity() > 1000 {
let old = thumbnail_renders.insert(parent_network_node_id, Vec::new());
@ -471,12 +480,13 @@ impl NodeRuntime {
return;
}
let bounds = match graphic.bounding_box(DAffine2::IDENTITY, true) {
RenderBoundingBox::None => None,
RenderBoundingBox::Infinite => Some([DVec2::ZERO, DVec2::new(300., 200.)]),
RenderBoundingBox::Rectangle(bounds) => Some(bounds),
// Fall back to a 1×1 rectangle if no caller offered finite bounds, then aspect-correct to the panel's 3:2 ratio
let raw_bounds = match bounds {
RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds,
_ => [DVec2::ZERO, DVec2::ONE],
};
let new_thumbnail_svg = if let Some(bounds) = bounds {
let bounds = expand_to_thumbnail_aspect(raw_bounds);
let new_thumbnail_svg = {
let footprint = Footprint {
transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)),
resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32),
@ -496,8 +506,6 @@ impl NodeRuntime {
render.format_svg(bounds[0], bounds[1]);
render.svg
} else {
Vec::new()
};
// Update frontend thumbnail
@ -512,6 +520,41 @@ impl NodeRuntime {
}
}
/// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the
/// framing matches what's actually visible after clipping rather than the unclipped content extents.
fn artboard_clip_bounds(artboards: &Table<Table<Graphic>>) -> RenderBoundingBox {
let mut combined: Option<[DVec2; 2]> = None;
for index in 0..artboards.len() {
let location: DVec2 = artboards.attribute_cloned_or_default(graphene_std::ATTR_LOCATION, index);
let dimensions: DVec2 = artboards.attribute_cloned_or_default(graphene_std::ATTR_DIMENSIONS, index);
let bounds = [location, location + dimensions];
combined = Some(match combined {
Some(existing) => [existing[0].min(bounds[0]), existing[1].max(bounds[1])],
None => bounds,
});
}
match combined {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
/// Expands an AABB outward (centered) to match the Layers panel thumbnail's 3:2 aspect ratio, padding the smaller axis
/// so the input's extent is always preserved.
fn expand_to_thumbnail_aspect(bounds: [DVec2; 2]) -> [DVec2; 2] {
const THUMBNAIL_ASPECT_RATIO: f64 = 1.5;
let size = bounds[1] - bounds[0];
let center = (bounds[0] + bounds[1]) / 2.;
let (width, height) = if size.x >= size.y * THUMBNAIL_ASPECT_RATIO {
(size.x, size.x / THUMBNAIL_ASPECT_RATIO)
} else {
(size.y * THUMBNAIL_ASPECT_RATIO, size.y)
};
let half = DVec2::new(width, height) / 2.;
[center - half, center + half]
}
pub async fn introspect_node(path: &[NodeId]) -> Result<Arc<dyn std::any::Any + Send + Sync + 'static>, IntrospectError> {
let runtime = NODE_RUNTIME.lock();
if let Some(ref mut runtime) = runtime.as_ref() {

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| {
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 {