From 564bc54866688706c91d0dd375aefd7658a42ab9 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 16 Jun 2026 10:07:35 +0900 Subject: [PATCH 1/8] Use userSpaceOnUse to render gradient on SVG --- .../libraries/rendering/src/render_ext.rs | 56 +++++++++++-------- .../libraries/rendering/src/renderer.rs | 53 ++---------------- 2 files changed, 36 insertions(+), 73 deletions(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 9c6f670e4c..2ddafd6b0c 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -4,7 +4,7 @@ use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientType; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; @@ -45,7 +45,6 @@ pub trait RenderExt { element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, - transformed_bounds: DAffine2, render_params: &RenderParams, target: PaintTarget, ) -> Self::Output; @@ -61,7 +60,6 @@ impl RenderExt for List { _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _transformed_bounds: DAffine2, _render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { @@ -85,11 +83,10 @@ impl RenderExt for List { fn render( &self, svg_defs: &mut String, - _item_transform: DAffine2, + item_transform: DAffine2, element_transform: DAffine2, - stroke_transform: DAffine2, + _stroke_transform: DAffine2, bounds: DAffine2, - transformed_bounds: DAffine2, _render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { @@ -97,7 +94,7 @@ impl RenderExt for List { let Some(stops) = self.element(0) else { return 0 }; let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); - let gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); for (position, color, original_midpoint) in stops.interpolated_samples() { @@ -115,16 +112,30 @@ impl RenderExt for List { stop.push_str(" />") } - let transform_points = element_transform * stroke_transform * bounds * gradient_transform; - let start = transform_points.transform_point2(DVec2::ZERO); - let end = transform_points.transform_point2(DVec2::X); - - let gradient_transform = if transform_is_invertible(transformed_bounds) { - transformed_bounds.inverse() + // Need to cancel out the element's transform as it is already applied to the path itself. + let element_transform_inverse = if transform_is_invertible(element_transform) { + element_transform.inverse() } else { - DAffine2::IDENTITY // Ignore if the transform cannot be inverted (the bounds are zero). See issue #1944. + DAffine2::IDENTITY + }; + + let document_transform = item_transform * bounds * local_gradient_transform; + + let placement = match gradient_type { + // A sheared linear gradient is not expressible in vello, no matter what transform is applied to the vector or the brush. + // So to keep the SVG rendering consistent with vello, we replace the second column of the document transform + // with the perpendicular vector of the first column, which makes the gradient band always perpendicular to the axis, the same way vello renders it. + GradientType::Linear => { + let axis = document_transform.matrix2.x_axis; + DAffine2 { + matrix2: DMat2::from_cols(axis, axis.perp()), + translation: document_transform.translation, + } + } + // Radial is 2D, and both vello and SVG can keep the full matrix so a non-uniform/skewed transform makes an ellipse. + GradientType::Radial => document_transform, }; - let gradient_transform = format_transform_matrix(gradient_transform); + let gradient_transform = format_transform_matrix(element_transform_inverse * placement); let gradient_transform = if gradient_transform.is_empty() { String::new() } else { @@ -143,16 +154,15 @@ impl RenderExt for List { GradientType::Linear => { let _ = write!( svg_defs, - r#"{}"#, - gradient_id, start.x, start.y, end.x, end.y, stop + r#"{}"#, + gradient_id, stop ); } GradientType::Radial => { - let radius = (f64::powi(start.x - end.x, 2) + f64::powi(start.y - end.y, 2)).sqrt(); let _ = write!( svg_defs, - r#"{}"#, - gradient_id, start.x, start.y, radius, stop + r#"{}"#, + gradient_id, stop ); } } @@ -172,7 +182,6 @@ impl RenderExt for Stroke { _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _transformed_bounds: DAffine2, render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { @@ -233,7 +242,6 @@ impl RenderExt for List { element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, - transformed_bounds: DAffine2, render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { @@ -241,9 +249,9 @@ impl RenderExt for List { let paint_attr = target.paint_attr(); match fill_graphic { - Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target), + Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target), Some(Graphic::Gradient(gradient_list)) => { - let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target); + let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target); format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 3b9153000e..15a9f7e747 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -356,7 +356,6 @@ fn emit_svg_fill_path( element_transform: DAffine2, applied_stroke_transform: DAffine2, bounds_matrix: DAffine2, - transformed_bounds_matrix: DAffine2, render_params: &RenderParams, ) { render.leaf_tag("path", |attributes| { @@ -367,18 +366,7 @@ fn emit_svg_fill_path( } let defs = &mut attributes.0.svg_defs; let fill_attribute = fill_graphic_list - .map(|list| { - list.render( - defs, - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - PaintTarget::Fill, - ) - }) + .map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, render_params, PaintTarget::Fill)) .unwrap_or_else(|| r#" fill="none""#.to_string()); attributes.push_val(fill_attribute); }); @@ -1084,7 +1072,6 @@ impl Render for List { let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds); let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]); - let transformed_bounds_matrix = element_transform * DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]); let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]); let mut path = String::new(); @@ -1126,7 +1113,6 @@ impl Render for List { element_transform, applied_stroke_transform, bounds_matrix, - transformed_bounds_matrix, render_params, ); } @@ -1158,7 +1144,6 @@ impl Render for List { element_transform, applied_stroke_transform, bounds_matrix, - transformed_bounds_matrix, render_params, ); } @@ -1207,16 +1192,7 @@ impl Render for List { .stroke() .map(|stroke| { if stroke_graphic_list.as_ref().and_then(|l| l.element(0)).is_some() { - stroke.render( - defs, - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - &render_params, - PaintTarget::Stroke, - ) + stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke) } else { String::new() } @@ -1235,16 +1211,7 @@ impl Render for List { Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix, _ => stroke_bounds_matrix, }; - list.render( - defs, - item_transform, - element_transform, - applied_stroke_transform, - paint_bounds, - transformed_bounds_matrix, - &render_params, - PaintTarget::Stroke, - ) + list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke) }) .unwrap_or_else(|| r#" stroke="none""#.to_string()) } else { @@ -1256,18 +1223,7 @@ impl Render for List { } else { fill_graphic_list .as_deref() - .map(|list| { - list.render( - defs, - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - &render_params, - PaintTarget::Fill, - ) - }) + .map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill)) .unwrap_or_else(|| r#" fill="none""#.to_string()) }; @@ -1303,7 +1259,6 @@ impl Render for List { element_transform, applied_stroke_transform, bounds_matrix, - transformed_bounds_matrix, render_params, ); } From 69bb4fb3e31e0ae045534ee18dffe49d2d82a6e9 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 16 Jun 2026 10:09:44 +0900 Subject: [PATCH 2/8] Allow non-uniform transform to radial gradient on vello --- .../libraries/rendering/src/renderer.rs | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 15a9f7e747..b3de4850e3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -377,7 +377,7 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } -fn create_peniko_gradient_brush(gradient_list: &List, parent_vector: &Vector, parent_transform: &DAffine2, multiplied_transform: &DAffine2) -> Option { +fn create_peniko_gradient_brush(gradient_list: &List, parent_vector: &Vector, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); @@ -395,33 +395,25 @@ fn create_peniko_gradient_brush(gradient_list: &List, parent_vect let bounds = parent_vector.nonzero_bounding_box(); let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - let inverse_parent_transform = if transform_is_invertible(*parent_transform) { - parent_transform.inverse() - } else { - Default::default() - }; - let mod_points = inverse_parent_transform * multiplied_transform * bound_transform * gradient_transform; - - let start = mod_points.transform_point2(DVec2::ZERO); - let end = mod_points.transform_point2(DVec2::X); + // Map the unit gradient to device space with the full transform. + // Keeping the whole matrix so a non-uniform transform applies to the gradient, which can make a radial gradient into an ellipse. + // For a linear gradient, vello only uses the axis and always renders perpendicular bands, so the full matrix is equivalent to the two endpoints. + let gradient_to_device = multiplied_transform * bound_transform * gradient_transform; let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { GradientType::Linear => peniko::LinearGradientPosition { - start: to_point(start), - end: to_point(end), + start: to_point(DVec2::ZERO), + end: to_point(DVec2::X), } .into(), - GradientType::Radial => { - let radius = start.distance(end); - peniko::RadialGradientPosition { - start_center: to_point(start), - start_radius: 0., - end_center: to_point(start), - end_radius: radius as f32, - } - .into() + GradientType::Radial => peniko::RadialGradientPosition { + start_center: to_point(DVec2::ZERO), + start_radius: 0., + end_center: to_point(DVec2::ZERO), + end_radius: 1., } + .into(), }, extend: match spread_method { GradientSpreadMethod::Pad => peniko::Extend::Pad, @@ -433,7 +425,7 @@ fn create_peniko_gradient_brush(gradient_list: &List, parent_vect ..Default::default() }); - Some(brush) + Some((brush, gradient_to_device)) } // TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes. @@ -1352,7 +1344,7 @@ impl Render for List { scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); } Graphic::Gradient(list) => { - let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, element, &multiplied_transform) else { continue; }; @@ -1361,7 +1353,7 @@ impl Render for List { } else { Default::default() }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { @@ -1434,7 +1426,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); } Graphic::Gradient(list) => { - let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, element, &multiplied_transform) else { continue; }; let inverse_element_transform = if transform_is_invertible(element_transform) { @@ -1442,7 +1434,7 @@ impl Render for List { } else { Default::default() }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } From 91d75f25648118e45c33f90f40b0d844ccfbd411 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 17 Jun 2026 09:40:41 +0900 Subject: [PATCH 3/8] Position gradients absolutely instead of by bbox --- .../common_functionality/graph_modification_utils.rs | 6 ++---- node-graph/libraries/rendering/src/render_ext.rs | 4 ++-- node-graph/libraries/rendering/src/renderer.rs | 11 ++++------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 89055aaa3c..b6fd38f6fa 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -337,10 +337,8 @@ pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: & .map(|footprint| footprint.transform) .unwrap_or(metadata.document_to_viewport); } - let multiplied = metadata.transform_to_viewport(layer); - let bounds = metadata.nonzero_bounding_box(layer); - let bound_transform = glam::DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - multiplied * bound_transform + + metadata.transform_to_viewport(layer) } /// True when start→end (mapped through `transform` into viewport space) points predominantly rightward. For purely diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 2ddafd6b0c..660565c78f 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -86,7 +86,7 @@ impl RenderExt for List { item_transform: DAffine2, element_transform: DAffine2, _stroke_transform: DAffine2, - bounds: DAffine2, + _bounds: DAffine2, _render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { @@ -119,7 +119,7 @@ impl RenderExt for List { DAffine2::IDENTITY }; - let document_transform = item_transform * bounds * local_gradient_transform; + let document_transform = item_transform * local_gradient_transform; let placement = match gradient_type { // A sheared linear gradient is not expressible in vello, no matter what transform is applied to the vector or the brush. diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b3de4850e3..356086be34 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -377,7 +377,7 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } -fn create_peniko_gradient_brush(gradient_list: &List, parent_vector: &Vector, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { +fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); @@ -392,13 +392,10 @@ fn create_peniko_gradient_brush(gradient_list: &List, parent_vect }); } - let bounds = parent_vector.nonzero_bounding_box(); - let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - // Map the unit gradient to device space with the full transform. // Keeping the whole matrix so a non-uniform transform applies to the gradient, which can make a radial gradient into an ellipse. // For a linear gradient, vello only uses the axis and always renders perpendicular bands, so the full matrix is equivalent to the two endpoints. - let gradient_to_device = multiplied_transform * bound_transform * gradient_transform; + let gradient_to_device = multiplied_transform * gradient_transform; let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { @@ -1344,7 +1341,7 @@ impl Render for List { scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); } Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, element, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { continue; }; @@ -1426,7 +1423,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); } Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, element, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { continue; }; let inverse_element_transform = if transform_is_invertible(element_transform) { From 8a6fadc0b81cc4bf00a39de088ceafdb54cc18c2 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 17 Jun 2026 11:09:50 +0900 Subject: [PATCH 4/8] Fix SVG import by removing multiplication of bound's inverse transform --- .../graph_operation/graph_operation_message_handler.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 0ad37b4dfb..406eada3af 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -11,7 +11,6 @@ use glam::{DAffine2, DVec2, IVec2}; use graph_craft::descriptor; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::list::List; -use graphene_std::renderer::Quad; use graphene_std::renderer::convert_usvg_path::convert_usvg_path; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; @@ -684,7 +683,6 @@ fn import_usvg_node_inner( /// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer. fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap) { let subpaths = convert_usvg_path(path); - let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); // Skip creating a Transform node entirely when the SVG-native transform is identity. let node_transform = usvg_transform(node.abs_transform()); @@ -697,8 +695,7 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, } if let Some(fill) = path.fill() { - let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - apply_usvg_fill(fill, modify_inputs, bounds_transform, graphite_gradient_stops); + apply_usvg_fill(fill, modify_inputs, graphite_gradient_stops); } if let Some(stroke) = path.stroke() { apply_usvg_stroke(stroke, modify_inputs, node_transform); @@ -797,14 +794,13 @@ fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSpreadMet } } -fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) { +fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, graphite_gradient_stops: &HashMap) { modify_inputs.fill_set(match &fill.paint() { usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), usvg::Paint::LinearGradient(linear) => { let gradient_transform = usvg_transform(linear.transform()); let (start, end) = (DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64)); let (start, end) = (gradient_transform.transform_point2(start), gradient_transform.transform_point2(end)); - let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); let gradient_type = GradientType::Linear; @@ -834,7 +830,6 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b let center = DVec2::new(radial.cx() as f64, radial.cy() as f64); let edge = center + DVec2::X * radial.r().get() as f64; let (start, end) = (gradient_transform.transform_point2(center), gradient_transform.transform_point2(edge)); - let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); let gradient_type = GradientType::Radial; From 869ef93dd0bbde735d42670d3c34a1d3ebdfec45 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 19 Jun 2026 15:46:26 -0700 Subject: [PATCH 5/8] Represent sheared linear gradients via their equivalent gradient line --- .../libraries/rendering/src/render_ext.rs | 19 +++----------- .../libraries/rendering/src/renderer.rs | 25 +++++++++++++++---- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 660565c78f..f25bb069ff 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,10 +1,10 @@ -use crate::renderer::{RenderParams, format_transform_matrix, transform_is_invertible}; +use crate::renderer::{RenderParams, format_transform_matrix, gradient_placement, transform_is_invertible}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; -use glam::{DAffine2, DMat2, DVec2}; +use glam::{DAffine2, DVec2}; use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientType; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; @@ -121,20 +121,7 @@ impl RenderExt for List { let document_transform = item_transform * local_gradient_transform; - let placement = match gradient_type { - // A sheared linear gradient is not expressible in vello, no matter what transform is applied to the vector or the brush. - // So to keep the SVG rendering consistent with vello, we replace the second column of the document transform - // with the perpendicular vector of the first column, which makes the gradient band always perpendicular to the axis, the same way vello renders it. - GradientType::Linear => { - let axis = document_transform.matrix2.x_axis; - DAffine2 { - matrix2: DMat2::from_cols(axis, axis.perp()), - translation: document_transform.translation, - } - } - // Radial is 2D, and both vello and SVG can keep the full matrix so a non-uniform/skewed transform makes an ellipse. - GradientType::Radial => document_transform, - }; + let placement = gradient_placement(document_transform, gradient_type); let gradient_transform = format_transform_matrix(element_transform_inverse * placement); let gradient_transform = if gradient_transform.is_empty() { String::new() diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 356086be34..ee15a874a3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -18,7 +18,7 @@ use core_types::{ ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, has_paint_at, stroke_graphic_list_at}; @@ -377,6 +377,24 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } +/// Maps a gradient's `transform` into the frame handed to the renderer: radial keeps the full matrix +/// (so a non-uniform transform makes an ellipse), while linear is de-sheared to its equivalent gradient line +/// (the axis projected onto the band normal), which Vello can represent since it stores only the two endpoints. +pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientType) -> DAffine2 { + match gradient_type { + GradientType::Radial => transform, + GradientType::Linear => { + let axis = transform.matrix2.x_axis; + let band_normal = transform.matrix2.y_axis.perp(); + let line = if band_normal.length_squared() > 0. { axis.project_onto(band_normal) } else { axis }; + DAffine2 { + matrix2: DMat2::from_cols(line, line.perp()), + translation: transform.translation, + } + } + } +} + fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; @@ -392,10 +410,7 @@ fn create_peniko_gradient_brush(gradient_list: &List, multiplied_ }); } - // Map the unit gradient to device space with the full transform. - // Keeping the whole matrix so a non-uniform transform applies to the gradient, which can make a radial gradient into an ellipse. - // For a linear gradient, vello only uses the axis and always renders perpendicular bands, so the full matrix is equivalent to the two endpoints. - let gradient_to_device = multiplied_transform * gradient_transform; + let gradient_to_device = gradient_placement(multiplied_transform * gradient_transform, gradient_type); let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { From 4c52c95fc140184735630e346f035c42a8fd22ae Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 19 Jun 2026 19:44:20 -0700 Subject: [PATCH 6/8] Migration --- .../portfolio/document/document_message.rs | 2 + .../document/document_message_handler.rs | 38 ++++++++++++++++ .../graph_operation_message_handler.rs | 4 ++ .../messages/portfolio/document_migration.rs | 3 ++ .../graph_modification_utils.rs | 14 ++++++ .../tool/tool_messages/gradient_tool.rs | 2 + editor/src/node_graph_executor.rs | 5 +++ node-graph/libraries/core-types/src/lib.rs | 4 +- node-graph/libraries/core-types/src/list.rs | 4 ++ .../libraries/graphic-types/src/graphic.rs | 21 ++++++--- .../libraries/rendering/src/renderer.rs | 44 +++++++++++++------ .../libraries/vector-types/src/gradient.rs | 44 +++++++++++++++++++ .../vector-types/src/vector/style.rs | 2 + node-graph/nodes/path-bool/src/lib.rs | 17 ++++++- 14 files changed, 181 insertions(+), 23 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 203287fb58..a7fc5c9f40 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -225,6 +225,8 @@ pub enum DocumentMessage { UpdateClickTargets { click_targets: HashMap>>, }, + // TODO: Eventually remove this document upgrade code + MigrateLegacyGradients, UpdateOutlines { outlines: HashMap>>, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 1629232be9..cc8af9ef7d 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -124,6 +124,11 @@ pub struct DocumentMessageHandler { /// The path of the to the document file. #[serde(skip)] pub(crate) path: Option, + // TODO: Eventually remove this document upgrade code + /// Set when a freshly-opened document still has legacy bounding-box-relative gradients; the deferred gradient + /// migration converts them to absolute after the first graph run (when geometry bounds are available) and clears this. + #[serde(skip)] + pub(crate) pending_gradient_migration: bool, /// Path to network currently viewed in the node graph overlay. This will eventually be stored in each panel, so that multiple panels can refer to different networks #[serde(skip)] breadcrumb_network_path: Vec, @@ -181,6 +186,8 @@ impl Default for DocumentMessageHandler { // ============================================= name: DEFAULT_DOCUMENT_NAME.to_string(), path: None, + // TODO: Eventually remove this document upgrade code + pending_gradient_migration: false, breadcrumb_network_path: Vec::new(), selection_network_path: Vec::new(), document_undo_history: VecDeque::new(), @@ -1365,6 +1372,37 @@ impl MessageHandler> for DocumentMes .collect(); self.network_interface.update_click_targets(layer_click_targets); } + // TODO: Eventually remove this document upgrade code + DocumentMessage::MigrateLegacyGradients => { + if self.pending_gradient_migration { + self.pending_gradient_migration = false; + + // Read each layer's legacy gradient and compute its absolute form from the now-available local bounds + let layers: Vec<_> = self.metadata().all_layers().collect(); + let conversions: Vec<(NodeId, Fill)> = layers + .into_iter() + .filter_map(|layer| { + let gradient = graph_modification_utils::get_gradient(layer, &self.network_interface)?; + if gradient.absolute { + return None; + } + let fill_node_id = graph_modification_utils::get_fill_node_id(layer, &self.network_interface)?; + let bounds = self.metadata().nonzero_bounding_box(layer); + let bounding_box = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + Some((fill_node_id, Fill::Gradient(gradient.to_absolute(bounding_box)))) + }) + .collect(); + + let converted_any = !conversions.is_empty(); + for (fill_node_id, fill) in conversions { + self.network_interface + .set_input(&InputConnector::node(fill_node_id, 1), NodeInput::value(TaggedValue::Fill(fill), false), &[]); + } + if converted_any { + responses.add(NodeGraphMessage::RunDocumentGraph); + } + } + } DocumentMessage::UpdateOutlines { outlines } => { let layer_outlines = outlines .into_iter() diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 406eada3af..6b16391725 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -823,6 +823,8 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g gradient_type, stops, spread_method, + // TODO: Eventually remove this document upgrade code + absolute: true, }) } usvg::Paint::RadialGradient(radial) => { @@ -852,6 +854,8 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g gradient_type, stops, spread_method, + // TODO: Eventually remove this document upgrade code + absolute: true, }) } usvg::Paint::Pattern(_) => { diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index e754c6b3bb..57d77a5c20 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1121,6 +1121,9 @@ pub fn document_migration_replace_resources_referenced_by_hash(document_serializ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) { document.network_interface.migrate_path_modify_node(); + // Legacy `Fill::Gradient`s are converted to absolute by the deferred migration pass once the first graph run yields geometry bounds + document.pending_gradient_migration = true; + let network = document.network_interface.document_network().clone(); // Apply string and node replacements to each node diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index b6fd38f6fa..e448781fd7 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -292,6 +292,12 @@ pub fn get_upstream_gradient_value_node_id(layer: LayerNodeIdentifier, network_i .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER))) } +// TODO: Eventually remove this document upgrade code +/// Get the layer's "Fill" node itself (whose `fill` input holds the paint value), not the node feeding that input. +pub fn get_fill_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER)) +} + /// Get the node connected to Fill's fill input, if any. pub fn get_fill_input_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let fill_node_id = NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))?; @@ -338,6 +344,14 @@ pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: & .unwrap_or(metadata.document_to_viewport); } + // TODO: Eventually remove this document upgrade code + // Only an existing legacy `Fill::Gradient` is in (0, 0)..(1, 1) bounding-box space; migrated and newly-created gradients are absolute (layer space). + if get_gradient(layer, network_interface).is_some_and(|gradient| !gradient.absolute) { + let bounds = metadata.nonzero_bounding_box(layer); + let bound_transform = glam::DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + return metadata.transform_to_viewport(layer) * bound_transform; + } + metadata.transform_to_viewport(layer) } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index fdb4aec4ae..2b0c3a1681 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -363,6 +363,8 @@ fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter spread_method: chain_state.spread_method, start: chain_state.transform.transform_point2(DVec2::ZERO), end: chain_state.transform.transform_point2(DVec2::X), + // TODO: Eventually remove this document upgrade code + absolute: true, }) } else { // Try to find a legacy Fill::Gradient that is selected in a Fill node diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 4e5182736c..8a37cff033 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -458,6 +458,11 @@ impl NodeGraphExecutor { first_element_source_id, }); responses.add(DocumentMessage::UpdateClickTargets { click_targets }); + + // TODO: Eventually remove this document upgrade code + // Runs after click targets land (this graph run's geometry bounds) so the deferred gradient migration can use them. + responses.add(DocumentMessage::MigrateLegacyGradients); + responses.add(DocumentMessage::UpdateOutlines { outlines }); responses.add(DocumentMessage::UpdateTextFrames { text_frames }); responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 878e7f5360..5f6f30f605 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,8 +25,8 @@ pub use graphene_hash; pub use graphene_hash::CacheHash; pub use list::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END, - ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, - ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_LEGACY, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, + ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, }; pub use memo::MemoHash; pub use no_std_types::AsU32; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index bd4780e3a7..a0b987d7f8 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -58,6 +58,10 @@ pub const ATTR_CLIP: &str = "clip"; pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; +// TODO: Eventually remove this document upgrade code +/// `bool` runtime marker (never serialized) flagging a gradient that came from the legacy bounding-box-relative `Fill::Gradient`, +/// so the renderer reproduces the pre-#4241 positioning instead of the new absolute path. +pub const ATTR_GRADIENT_LEGACY: &str = "gradient_legacy"; /// Vector graphics object's filled area paint, of type List where T is any graphic type. pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 458c549bf3..ede6bd4bfb 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -4,7 +4,7 @@ use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List}; use core_types::ops::{FromAnchorPosition, ListConvert}; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; -use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_GRADIENT_LEGACY, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use raster_types::{CPU, GPU, Raster}; @@ -193,15 +193,24 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) /// Converts a `Fill` enum into the `List` representation used as paint storage. /// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. -pub fn fill_to_graphic_list(fill: &Fill) -> Option> { +pub fn fill_to_graphic_list(fill: &Fill, bounding_box_transform: DAffine2) -> Option> { match fill { Fill::None => None, Fill::Solid(color) => Some(List::new_from_element((*color).into())), Fill::Gradient(gradient) => { + // TODO: Eventually remove this document upgrade code + // Absolute gradients carry their transform directly into the new pipeline. Legacy ones are bounding-box-relative, + // so bake the bbox in and flag them to reproduce the pre-#4241 rendering until the deferred migration converts them. + let (transform, legacy) = if gradient.absolute { + (gradient.to_transform(), false) + } else { + (bounding_box_transform * gradient.to_transform(), true) + }; let gradient_item = Item::new_from_element(gradient.stops.clone()) - .with_attribute(ATTR_TRANSFORM, gradient.to_transform()) + .with_attribute(ATTR_TRANSFORM, transform) .with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type) - .with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method); + .with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method) + .with_attribute(ATTR_GRADIENT_LEGACY, legacy); let gradient_list = List::new_from_item(gradient_item); Some(List::new_from_element(Graphic::Gradient(gradient_list))) @@ -246,7 +255,9 @@ pub fn has_paint_at(list: &List, index: usize, attribute: &str) -> bool pub fn fill_graphic_list_at(list: &List, index: usize) -> Option>> { graphic_list_at(list, index, ATTR_FILL).or_else(|| { let vector = list.element(index)?; - fill_to_graphic_list(vector.style.fill()).map(Cow::Owned) + let bounds = vector.nonzero_bounding_box(); + let bounding_box_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + fill_to_graphic_list(vector.style.fill(), bounding_box_transform).map(Cow::Owned) }) } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index ee15a874a3..f9ac14fc1d 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -14,8 +14,8 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, - ATTR_TEXT_ALIGN, ATTR_TRANSFORM, + ATTR_FONT_SIZE, ATTR_GRADIENT_LEGACY, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, + ATTR_SPREAD_METHOD, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; @@ -377,9 +377,10 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } -/// Maps a gradient's `transform` into the frame handed to the renderer: radial keeps the full matrix -/// (so a non-uniform transform makes an ellipse), while linear is de-sheared to its equivalent gradient line -/// (the axis projected onto the band normal), which Vello can represent since it stores only the two endpoints. +/// Maps a gradient's `transform` into the frame handed to the renderer: radial keeps the full matrix (so a +/// non-uniform transform makes an ellipse), while linear is reduced to the equivalent non-sheared gradient line (the +/// axis projected onto the band normal) so the iso-color bands keep following a sheared transform, which Vello can +/// represent since it stores only two endpoints. pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientType) -> DAffine2 { match gradient_type { GradientType::Radial => transform, @@ -395,12 +396,14 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientTyp } } -fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { +fn create_peniko_gradient_brush(gradient_list: &List, parent_transform: &DAffine2, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let spread_method: GradientSpreadMethod = gradient_list.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + // TODO: Eventually remove this document upgrade code + let legacy_bounding_box: bool = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_LEGACY, 0); let mut peniko_stops = peniko::ColorStops::new(); for (position, color, _) in stops.interpolated_samples() { @@ -410,20 +413,33 @@ fn create_peniko_gradient_brush(gradient_list: &List, multiplied_ }); } - let gradient_to_device = gradient_placement(multiplied_transform * gradient_transform, gradient_type); + // TODO: Eventually remove this document upgrade code + // Legacy bounding-box gradients reproduce the pre-#4241 renderer: literal endpoints in the layer's own space, with the + // parent transform applied as the brush so its shear bends the bands. New gradients use the unit gradient placed by the desheared frame. + let (start, end, gradient_to_device) = if legacy_bounding_box { + let inverse_parent_transform = if transform_is_invertible(*parent_transform) { + parent_transform.inverse() + } else { + Default::default() + }; + let mod_points = inverse_parent_transform * *multiplied_transform * gradient_transform; + (mod_points.transform_point2(DVec2::ZERO), mod_points.transform_point2(DVec2::X), *parent_transform) + } else { + (DVec2::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_type)) + }; let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_type { GradientType::Linear => peniko::LinearGradientPosition { - start: to_point(DVec2::ZERO), - end: to_point(DVec2::X), + start: to_point(start), + end: to_point(end), } .into(), GradientType::Radial => peniko::RadialGradientPosition { - start_center: to_point(DVec2::ZERO), + start_center: to_point(start), start_radius: 0., - end_center: to_point(DVec2::ZERO), - end_radius: 1., + end_center: to_point(start), + end_radius: start.distance(end) as f32, } .into(), }, @@ -1356,7 +1372,7 @@ impl Render for List { scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); } Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &parent_transform, &multiplied_transform) else { continue; }; @@ -1438,7 +1454,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); } Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &parent_transform, &multiplied_transform) else { continue; }; let inverse_element_transform = if transform_is_invertible(element_transform) { diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index e3d61c12e9..6019590c3e 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -484,6 +484,12 @@ pub struct Gradient { pub end: DVec2, #[cfg_attr(feature = "serde", serde(default))] pub spread_method: GradientSpreadMethod, + // TODO: Eventually remove this document upgrade code + /// Whether `start`/`end` are absolute (layer-space) rather than in the legacy [0,1] bounding-box space. + /// Documents predating the gradient migration deserialize this as `false`; the deferred migration converts + /// them and sets it `true`. Once all documents are migrated, the legacy rendering path can be removed. + #[cfg_attr(feature = "serde", serde(default))] + pub absolute: bool, } impl Default for Gradient { @@ -494,6 +500,8 @@ impl Default for Gradient { start: DVec2::new(0., 0.5), end: DVec2::new(1., 0.5), spread_method: GradientSpreadMethod::Pad, + // TODO: Eventually remove this document upgrade code + absolute: true, } } } @@ -512,6 +520,38 @@ impl std::fmt::Display for Gradient { } impl Gradient { + // TODO: Eventually remove this document upgrade code + /// Converts a legacy bounding-box-relative gradient (`start`/`end` in [0,1]) into an absolute one whose `start`/`end` + /// are in the geometry's local space, chosen so the deshearing render pipeline reproduces the legacy appearance. + /// `bounding_box` maps [0,1] onto the geometry's bounding box. + pub fn to_absolute(&self, bounding_box: DAffine2) -> Gradient { + let (start, end) = match self.gradient_type { + // The deshearing render is field-preserving, so place the end where the field's gradient (the band normal of + // `bounding_box * to_transform`) reaches t = 1, rather than the visually-mapped end which would shear differently. + GradientType::Linear => { + let field_frame = bounding_box * self.to_transform(); + let determinant = field_frame.matrix2.determinant(); + let start = bounding_box.transform_point2(self.start); + if determinant.recip().is_finite() { + let field = -field_frame.matrix2.y_axis.perp() / determinant; + (start, start + field / field.length_squared()) + } else { + (start, bounding_box.transform_point2(self.end)) + } + } + // The radial brush is isotropic, so the bbox-mapped endpoints reproduce it (exactly for similarity layer transforms). + GradientType::Radial => (bounding_box.transform_point2(self.start), bounding_box.transform_point2(self.end)), + }; + + Gradient { + start, + end, + // TODO: Eventually remove this document upgrade code + absolute: true, + ..self.clone() + } + } + /// Constructs a new gradient with the colors at 0 and 1 specified. pub fn new(start: DVec2, start_color: Color, end: DVec2, end_color: Color, gradient_type: GradientType, spread_method: GradientSpreadMethod) -> Self { let stops = GradientStops::new([ @@ -533,6 +573,8 @@ impl Gradient { stops, gradient_type, spread_method, + // TODO: Eventually remove this document upgrade code + absolute: true, } } @@ -554,6 +596,8 @@ impl Gradient { stops, gradient_type, spread_method, + // TODO: Eventually remove this document upgrade code + absolute: self.absolute, } } diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index f402727ae1..de73e23c02 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -153,6 +153,8 @@ impl From> for Fill { spread_method, start: transform.transform_point2(DVec2::ZERO), end: transform.transform_point2(DVec2::X), + // TODO: Eventually remove this document upgrade code + absolute: true, }) } } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 2f686483ce..82519a91e5 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -1,7 +1,11 @@ use core_types::list::{Item, List}; use core_types::uuid::NodeId; -use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, BlendMode, Color, Ctx}; +use core_types::{ + ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, BlendMode, Color, + Ctx, +}; use glam::{DAffine2, DVec2}; +use graphic_types::vector_types::gradient::{Gradient, GradientSpreadMethod, GradientType}; use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath}; use graphic_types::vector_types::vector::PointId; use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; @@ -272,7 +276,16 @@ fn flatten_vector(graphic_list: &List) -> List { .map(|row| { let (stops, attributes) = row.into_parts(); let mut element = Vector::default(); - element.style.set_fill(Fill::Gradient(graphic_types::vector_types::gradient::Gradient { stops, ..Default::default() })); + // Convert the gradient's transform to absolute endpoints, matching `From> for Fill` + let transform = attributes.get::(ATTR_TRANSFORM).cloned().unwrap_or_default(); + element.style.set_fill(Fill::Gradient(Gradient { + stops, + gradient_type: attributes.get::(ATTR_GRADIENT_TYPE).cloned().unwrap_or_default(), + spread_method: attributes.get::(ATTR_SPREAD_METHOD).cloned().unwrap_or_default(), + start: transform.transform_point2(DVec2::ZERO), + end: transform.transform_point2(DVec2::X), + absolute: true, + })); element.style.set_stroke_transform(DAffine2::IDENTITY); Item::from_parts(element, attributes) From 66076e4bb79da3f73e8d2aaed67f37df5ebc8758 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 20 Jun 2026 12:50:37 -0700 Subject: [PATCH 7/8] Fix migration for radial (elliptical) gradients --- .../data_panel/data_panel_message_handler.rs | 4 ++ .../document/document_message_handler.rs | 3 +- .../graph_operation_message_handler.rs | 2 + .../tool/tool_messages/gradient_tool.rs | 1 + .../libraries/graphic-types/src/graphic.rs | 10 ++-- .../libraries/vector-types/src/gradient.rs | 54 +++++++++++-------- .../vector-types/src/vector/style.rs | 1 + node-graph/nodes/path-bool/src/lib.rs | 1 + 8 files changed, 50 insertions(+), 26 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index c321e2385f..6b2a8dea05 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -423,6 +423,10 @@ impl TableItemLayout for Vector { TextLabel::new("Fill Gradient End").narrow(true).widget_instance(), TextLabel::new(format_dvec2(gradient.end)).narrow(true).widget_instance(), ]); + table_rows.push(vec![ + TextLabel::new("Fill Gradient Transform").narrow(true).widget_instance(), + TextLabel::new(format_transform_matrix(gradient.transform)).narrow(true).widget_instance(), + ]); } } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index cc8af9ef7d..50ddd10ea4 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1389,7 +1389,8 @@ impl MessageHandler> for DocumentMes let fill_node_id = graph_modification_utils::get_fill_node_id(layer, &self.network_interface)?; let bounds = self.metadata().nonzero_bounding_box(layer); let bounding_box = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - Some((fill_node_id, Fill::Gradient(gradient.to_absolute(bounding_box)))) + let layer_transform = self.metadata().upstream_transform(layer.to_node()); + Some((fill_node_id, Fill::Gradient(gradient.to_absolute(bounding_box, layer_transform)))) }) .collect(); diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 6b16391725..9c35da3ec7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -825,6 +825,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g spread_method, // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, }) } usvg::Paint::RadialGradient(radial) => { @@ -856,6 +857,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g spread_method, // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, }) } usvg::Paint::Pattern(_) => { diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 2b0c3a1681..2eb8a96148 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -365,6 +365,7 @@ fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter end: chain_state.transform.transform_point2(DVec2::X), // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, }) } else { // Try to find a legacy Fill::Gradient that is selected in a Fill node diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index ede6bd4bfb..d7eb17aa68 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -198,13 +198,15 @@ pub fn fill_to_graphic_list(fill: &Fill, bounding_box_transform: DAffine2) -> Op Fill::None => None, Fill::Solid(color) => Some(List::new_from_element((*color).into())), Fill::Gradient(gradient) => { + let gradient_transform = gradient.transform * gradient.to_transform(); + // TODO: Eventually remove this document upgrade code - // Absolute gradients carry their transform directly into the new pipeline. Legacy ones are bounding-box-relative, - // so bake the bbox in and flag them to reproduce the pre-#4241 rendering until the deferred migration converts them. + // Absolute gradients carry their effective frame into the new pipeline; legacy bounding-box gradients bake the bbox + // in and flag the legacy render path until the deferred migration converts them. let (transform, legacy) = if gradient.absolute { - (gradient.to_transform(), false) + (gradient_transform, false) } else { - (bounding_box_transform * gradient.to_transform(), true) + (bounding_box_transform * gradient_transform, true) }; let gradient_item = Item::new_from_element(gradient.stops.clone()) .with_attribute(ATTR_TRANSFORM, transform) diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 6019590c3e..8dccedfbc0 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,7 +2,7 @@ use core_types::Color; use core_types::color::SRGBA8; use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DMat2, DVec2}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] @@ -490,6 +490,12 @@ pub struct Gradient { /// them and sets it `true`. Once all documents are migrated, the legacy rendering path can be removed. #[cfg_attr(feature = "serde", serde(default))] pub absolute: bool, + // TODO: Eventually remove this document upgrade code + /// An extra frame adjustment composed onto the `start`/`end` frame (`transform * to_transform()`), letting the gradient + /// describe shapes the endpoint pair cannot, such as an elliptical radial. It defaults to identity, so existing documents + /// (which only have `start`/`end`) are unaffected; the migration stores a non-identity value only where it's needed. + #[cfg_attr(feature = "serde", serde(default))] + pub transform: DAffine2, } impl Default for Gradient { @@ -502,6 +508,7 @@ impl Default for Gradient { spread_method: GradientSpreadMethod::Pad, // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, } } } @@ -521,31 +528,34 @@ impl std::fmt::Display for Gradient { impl Gradient { // TODO: Eventually remove this document upgrade code - /// Converts a legacy bounding-box-relative gradient (`start`/`end` in [0,1]) into an absolute one whose `start`/`end` - /// are in the geometry's local space, chosen so the deshearing render pipeline reproduces the legacy appearance. - /// `bounding_box` maps [0,1] onto the geometry's bounding box. - pub fn to_absolute(&self, bounding_box: DAffine2) -> Gradient { - let (start, end) = match self.gradient_type { - // The deshearing render is field-preserving, so place the end where the field's gradient (the band normal of - // `bounding_box * to_transform`) reaches t = 1, rather than the visually-mapped end which would shear differently. - GradientType::Linear => { - let field_frame = bounding_box * self.to_transform(); - let determinant = field_frame.matrix2.determinant(); - let start = bounding_box.transform_point2(self.start); - if determinant.recip().is_finite() { - let field = -field_frame.matrix2.y_axis.perp() / determinant; - (start, start + field / field.length_squared()) - } else { - (start, bounding_box.transform_point2(self.end)) - } - } - // The radial brush is isotropic, so the bbox-mapped endpoints reproduce it (exactly for similarity layer transforms). - GradientType::Radial => (bounding_box.transform_point2(self.start), bounding_box.transform_point2(self.end)), + /// Converts a legacy bounding-box-relative gradient (`start`/`end` in [0,1]) into an absolute one in the geometry's local space. + /// `bounding_box` maps [0,1] onto the geometry's bounding box; `layer_transform` is the layer's own transform, + /// used to bake the elliptical adjustment that reproduces the legacy isotropic radial through a non-uniform layer. + pub fn to_absolute(&self, bounding_box: DAffine2, layer_transform: DAffine2) -> Gradient { + let start = bounding_box.transform_point2(self.start); + let end = bounding_box.transform_point2(self.end); + let direction = end - start; + + // The legacy radial drew as a circle in the layer's own space; bake the adjustment that, composed with the + // endpoint frame, makes the new pipeline reproduce that circle through the (possibly non-uniform) layer transform. + let radial_invertible = + self.gradient_type == GradientType::Radial && layer_transform.is_finite() && layer_transform.matrix2.determinant().recip().is_finite() && direction.length_squared() > 1e-20; + let transform = if radial_invertible { + let radius = (layer_transform.matrix2 * direction).length(); + let circle = DAffine2 { + matrix2: DMat2::from_diagonal(DVec2::splat(radius)), + translation: layer_transform.transform_point2(start), + }; + let base = DAffine2::from_cols(direction, direction.perp(), start); + (layer_transform.inverse() * circle) * base.inverse() + } else { + DAffine2::IDENTITY }; Gradient { start, end, + transform, // TODO: Eventually remove this document upgrade code absolute: true, ..self.clone() @@ -575,6 +585,7 @@ impl Gradient { spread_method, // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, } } @@ -598,6 +609,7 @@ impl Gradient { spread_method, // TODO: Eventually remove this document upgrade code absolute: self.absolute, + transform: if time < 0.5 { self.transform } else { other.transform }, } } diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index de73e23c02..2f7ad4aaed 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -155,6 +155,7 @@ impl From> for Fill { end: transform.transform_point2(DVec2::X), // TODO: Eventually remove this document upgrade code absolute: true, + transform: DAffine2::IDENTITY, }) } } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 82519a91e5..0ae58adaad 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -285,6 +285,7 @@ fn flatten_vector(graphic_list: &List) -> List { start: transform.transform_point2(DVec2::ZERO), end: transform.transform_point2(DVec2::X), absolute: true, + transform: DAffine2::IDENTITY, })); element.style.set_stroke_transform(DAffine2::IDENTITY); From 20da712ccfbbee46b2c963eaa173a23ee67a0ef7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 20 Jun 2026 13:40:45 -0700 Subject: [PATCH 8/8] Fix boolean operation gradients --- node-graph/libraries/vector-types/src/vector/style.rs | 4 ++++ node-graph/nodes/path-bool/src/lib.rs | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 2f7ad4aaed..4ae63fe83c 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -671,6 +671,10 @@ impl PathStyle { &self.fill } + pub fn fill_mut(&mut self) -> &mut Fill { + &mut self.fill + } + /// Get the current path's [Stroke]. /// /// # Example diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 0ae58adaad..685daed2b1 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -129,13 +129,20 @@ fn boolean_operation_on_vector_list(vector: &List, boolean_operation: Bo }; let mut row = if let Some(index) = copy_from_index { let mut attributes = vector.clone_item_attributes(index); + let copy_from_transform: DAffine2 = vector.attribute_cloned_or_default(ATTR_TRANSFORM, index); // The boolean op bakes input transforms into the output geometry, so the result item carries no transform of its own attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY); let copy_from = vector.element(index).unwrap(); - let element = Vector { + let mut element = Vector { style: copy_from.style.clone(), ..Default::default() }; + // An absolute gradient lives in the geometry's space, so bake the same transform into it to track the baked points + if let Fill::Gradient(gradient) = element.style.fill_mut() + && gradient.absolute + { + gradient.transform = copy_from_transform * gradient.transform; + } Item::from_parts(element, attributes) } else { Item::::default()