From 902c3ed74b1246a697bc8463bb099a85362459a2 Mon Sep 17 00:00:00 2001 From: afrdbaig7 Date: Sun, 26 Apr 2026 12:03:36 +0530 Subject: [PATCH 1/3] add elliptical support to radial gradients This update adds an spect field and minor-axis handles to control shape. Rendering adjusts transforms (SVG/Vello) to simulate ellipses while keeping existing gradients unchanged --- .../tool/tool_messages/gradient_tool.rs | 119 +++++++++++++++++- .../libraries/rendering/src/render_ext.rs | 37 ++++-- .../libraries/rendering/src/renderer.rs | 16 ++- .../libraries/vector-types/src/gradient.rs | 13 ++ 4 files changed, 174 insertions(+), 11 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index f6b8f49be9..f7edbe5dee 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -294,6 +294,10 @@ pub enum GradientDragTarget { Stop(usize), Midpoint(usize), New, + /// Drag the +minor-axis handle (perpendicular to major axis, toward +perp direction) + RadialMinorPos, + /// Drag the −minor-axis handle (perpendicular to major axis, toward −perp direction) + RadialMinorNeg, } /// Contains information about the selected gradient handle @@ -341,7 +345,17 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D return Some(projection); } - None +/// Compute minor-axis handle positions in document space for a radial gradient. +fn radial_minor_handles(gradient: &Gradient) -> Option<(DVec2, DVec2)> { + let major_vec = gradient.end - gradient.start; + let major_len = major_vec.length(); + if major_len < f64::EPSILON { + return None; + } + let minor_len = major_len * gradient.aspect; + let minor_dir = (major_vec / major_len).perp(); + let center = gradient.start; + Some((center + minor_dir * minor_len, center - minor_dir * minor_len)) } impl SelectedGradient { @@ -561,6 +575,28 @@ impl SelectedGradient { self.gradient.stops.midpoint[midpoint_index] = midpoint_ratio; } } + GradientDragTarget::RadialMinorPos | GradientDragTarget::RadialMinorNeg => { + let document_to_viewport = snap_data.document.metadata().document_to_viewport; + let mouse_doc = document_to_viewport.inverse().transform_point2(mouse); + + let center_doc = self.gradient.start; + let major_vec = self.gradient.end - center_doc; + let major_len = major_vec.length(); + + if major_len < f64::EPSILON { + self.render_gradient(responses); + return; + } + + let minor_dir = (major_vec / major_len).perp(); + let minor_dist = (mouse_doc - center_doc).dot(minor_dir).abs(); + + if snap_rotate { + self.gradient.aspect = 1.; + } else { + self.gradient.aspect = (minor_dist / major_len).clamp(0.01, 10.); + } + } } self.render_gradient(responses); } @@ -810,6 +846,34 @@ impl Fsm for GradientToolFsmState { } } + if gradient.gradient_type == GradientType::Radial { + let major_vec = end - start; + let major_len = major_vec.length(); + if major_len > f64::EPSILON { + let minor_len = major_len * gradient.aspect; + let major_dir = major_vec / major_len; + let minor_dir = major_dir.perp(); + let center = start; + + let minor_pos_vp = center + minor_dir * minor_len; + let minor_neg_vp = center - minor_dir * minor_len; + + let angle = major_dir.y.atan2(major_dir.x); + overlay_context.dashed_ellipse(center, major_len, minor_len, Some(angle), None, None, None, None, Some(COLOR_OVERLAY_BLUE), Some(4.), Some(4.), None); + + overlay_context.line(center, minor_pos_vp, Some(COLOR_OVERLAY_BLUE), None); + overlay_context.line(center, minor_neg_vp, Some(COLOR_OVERLAY_BLUE), None); + + let minor_tol_sq = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + let pos_active = dragging == Some(GradientDragTarget::RadialMinorPos); + let neg_active = dragging == Some(GradientDragTarget::RadialMinorNeg); + let pos_hovered = !pos_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_pos_vp.distance_squared(mouse) < minor_tol_sq; + let neg_hovered = !neg_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_neg_vp.distance_squared(mouse) < minor_tol_sq; + overlay_context.manipulator_handle(minor_pos_vp, pos_active || pos_hovered, None); + overlay_context.manipulator_handle(minor_neg_vp, neg_active || neg_hovered, None); + } + } + let snap_data = SnapData::new(document, input, viewport); tool_data.snap_manager.draw_overlays(snap_data, &mut overlay_context); @@ -1048,6 +1112,33 @@ impl Fsm for GradientToolFsmState { let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; let transform = gradient_space_transform(layer, document); + if drag_hint.is_none() && gradient.gradient_type == GradientType::Radial { + if let Some((minor_pos_doc, minor_neg_doc)) = radial_minor_handles(&gradient) { + let minor_pos_vp = transform.transform_point2(minor_pos_doc); + let minor_neg_vp = transform.transform_point2(minor_neg_doc); + let minor_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + + let minor_drag_target = if minor_pos_vp.distance_squared(mouse) < minor_tolerance { + Some(GradientDragTarget::RadialMinorPos) + } else if minor_neg_vp.distance_squared(mouse) < minor_tolerance { + Some(GradientDragTarget::RadialMinorNeg) + } else { + None + }; + + if let Some(drag_target) = minor_drag_target { + drag_hint = Some(GradientDragHintState::RadialMinor); + tool_data.selected_gradient = Some(SelectedGradient { + layer: Some(layer), + transform, + gradient: gradient.clone(), + dragging: drag_target, + initial_gradient: gradient.clone(), + }); + } + } + } + // Check for dragging a midpoint diamond if drag_hint.is_none() { let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); @@ -1380,6 +1471,12 @@ impl Fsm for GradientToolFsmState { groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Reset Midpoint")])); } } + GradientHoverTarget::RadialMinor => { + groups.push(HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDrag, "Adjust Ellipse"), + HintInfo::keys([Key::Shift], "Snap to Circle").prepend_plus(), + ])); + } } // Delete/reset hint based on selection @@ -1414,6 +1511,9 @@ impl Fsm for GradientToolFsmState { GradientDragHintState::Midpoint { resettable: true } => { groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Reset Midpoint")])); } + GradientDragHintState::RadialMinor => { + groups.push(HintGroup(vec![HintInfo::keys([Key::Shift], "Snap to Circle")])); + } _ => {} } @@ -1449,6 +1549,17 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); let line_length = start.distance(end); + if gradient.gradient_type == GradientType::Radial { + if let Some((minor_pos_doc, minor_neg_doc)) = radial_minor_handles(&gradient) { + let minor_pos_vp = transform.transform_point2(minor_pos_doc); + let minor_neg_vp = transform.transform_point2(minor_neg_doc); + let minor_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + if minor_pos_vp.distance_squared(mouse) < minor_tolerance || minor_neg_vp.distance_squared(mouse) < minor_tolerance { + return GradientHoverTarget::RadialMinor; + } + } + } + // Check midpoint diamonds first (smaller hit area, higher priority) for i in 0..gradient.stops.position.len().saturating_sub(1) { let left = gradient.stops.position[i]; @@ -1506,7 +1617,7 @@ fn compute_selected_target(tool_data: &GradientToolData) -> GradientSelectedTarg let resettable = selected_gradient.gradient.stops.midpoint.get(i).is_some_and(|&midpoint_value| midpoint_is_resettable(midpoint_value)); GradientSelectedTarget::Midpoint { resettable } } - GradientDragTarget::New => GradientSelectedTarget::None, + GradientDragTarget::New | GradientDragTarget::RadialMinorPos | GradientDragTarget::RadialMinorNeg => GradientSelectedTarget::None, } } @@ -1593,6 +1704,8 @@ enum GradientHoverTarget { Midpoint { resettable: bool, }, + /// Hovering over a radial minor-axis handle + RadialMinor, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -1615,6 +1728,8 @@ enum GradientDragHintState { Midpoint { resettable: bool, }, + /// Dragging a radial minor-axis handle to reshape the ellipse + RadialMinor, } #[cfg(test)] diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index df1690afd2..6ce7d82207 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -36,17 +36,11 @@ impl RenderExt for Gradient { let start = transform_points.transform_point2(self.start); let end = transform_points.transform_point2(self.end); - let gradient_transform = if transformed_bounds.matrix2.determinant() != 0. { + let gradient_transform_raw = if transformed_bounds.matrix2.determinant() != 0. { transformed_bounds.inverse() } else { DAffine2::IDENTITY // Ignore if the transform cannot be inverted (the bounds are zero). See issue #1944. }; - let gradient_transform = format_transform_matrix(gradient_transform); - let gradient_transform = if gradient_transform.is_empty() { - String::new() - } else { - format!(r#" gradientTransform="{gradient_transform}""#) - }; let spread_method = if self.spread_method == GradientSpreadMethod::Pad { String::new() @@ -58,6 +52,12 @@ impl RenderExt for Gradient { match self.gradient_type { GradientType::Linear => { + let gradient_transform = format_transform_matrix(gradient_transform_raw); + let gradient_transform = if gradient_transform.is_empty() { + String::new() + } else { + format!(r#" gradientTransform="{gradient_transform}""#) + }; let _ = write!( svg_defs, r#"{}"#, @@ -65,7 +65,28 @@ impl RenderExt for Gradient { ); } GradientType::Radial => { - let radius = (f64::powi(start.x - end.x, 2) + f64::powi(start.y - end.y, 2)).sqrt(); + let radius = start.distance(end); + + let ellipse_transform = if (self.aspect - 1.).abs() > f64::EPSILON { + let angle = (end - start).to_angle(); + let squash = DAffine2::from_translation(start) + * DAffine2::from_angle(angle) + * DAffine2::from_scale(DVec2::new(1., self.aspect)) + * DAffine2::from_angle(-angle) + * DAffine2::from_translation(-start); + + squash * gradient_transform_raw + } else { + gradient_transform_raw + }; + + let gradient_transform = format_transform_matrix(ellipse_transform); + let gradient_transform = if gradient_transform.is_empty() { + String::new() + } else { + format!(r#" gradientTransform="{gradient_transform}""#) + }; + let _ = write!( svg_defs, r#"{}"#, diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 6f1690ad37..f443df22e8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1174,7 +1174,21 @@ impl Render for Table { } else { Default::default() }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + let mut brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + + if gradient.gradient_type == GradientType::Radial && (gradient.aspect - 1.).abs() > f64::EPSILON { + let angle = (end - start).to_angle(); + let center = kurbo::Vec2::new(start.x, start.y); + + let ellipse_affine = kurbo::Affine::translate(center) + * kurbo::Affine::rotate(angle) + * kurbo::Affine::scale_non_uniform(1., gradient.aspect) + * kurbo::Affine::rotate(-angle) + * kurbo::Affine::translate(-center); + + brush_transform = ellipse_affine * brush_transform; + } + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); } Fill::None => {} diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index f5c241c2f0..46240d9ac3 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -368,6 +368,14 @@ pub struct Gradient { pub end: DVec2, #[serde(default)] pub spread_method: GradientSpreadMethod, + /// Ratio of the minor radius to the major radius for radial gradients (1.0 = circle). + /// Ignored for linear gradients. Defaults to 1.0 so old documents deserialize as circles. + #[serde(default = "default_aspect")] + pub aspect: f64, +} + +fn default_aspect() -> f64 { + 1. } impl Default for Gradient { @@ -378,6 +386,7 @@ impl Default for Gradient { start: DVec2::new(0., 0.5), end: DVec2::new(1., 0.5), spread_method: GradientSpreadMethod::Pad, + aspect: 1., } } } @@ -390,6 +399,7 @@ impl std::hash::Hash for Gradient { .chain(self.end.to_array().iter()) .chain(self.stops.position.iter()) .chain(self.stops.midpoint.iter()) + .chain(std::iter::once(&self.aspect)) .for_each(|x| x.to_bits().hash(state)); self.stops.color.iter().for_each(|color| color.hash(state)); self.gradient_type.hash(state); @@ -432,6 +442,7 @@ impl Gradient { stops, gradient_type, spread_method, + aspect: 1., } } @@ -446,6 +457,7 @@ impl Gradient { let stops = GradientStops::new(stops); let gradient_type = if time < 0.5 { self.gradient_type } else { other.gradient_type }; let spread_method = if time < 0.5 { self.spread_method } else { other.spread_method }; + let aspect = self.aspect + (other.aspect - self.aspect) * time; Self { start, @@ -453,6 +465,7 @@ impl Gradient { stops, gradient_type, spread_method, + aspect, } } From 78d3adf88f802f5b5405df9fafd10c740981104a Mon Sep 17 00:00:00 2001 From: afrdbaig7 Date: Sun, 26 Apr 2026 12:30:29 +0530 Subject: [PATCH 2/3] Fix compiler errors and formatting for elliptical gradients --- editor/src/messages/tool/tool_messages/gradient_tool.rs | 2 ++ node-graph/libraries/rendering/src/render_ext.rs | 7 ++++--- node-graph/libraries/rendering/src/renderer.rs | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index f7edbe5dee..844f545d79 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -344,6 +344,8 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D return Some(projection); } + None +} /// Compute minor-axis handle positions in document space for a radial gradient. fn radial_minor_handles(gradient: &Gradient) -> Option<(DVec2, DVec2)> { diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 6ce7d82207..e06451623c 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,6 +1,6 @@ use crate::renderer::{RenderParams, format_transform_matrix}; use core_types::uuid::generate_uuid; -use glam::DAffine2; +use glam::{DAffine2, DVec2}; use graphic_types::vector_types::gradient::{Gradient, GradientType}; use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; @@ -68,13 +68,14 @@ impl RenderExt for Gradient { let radius = start.distance(end); let ellipse_transform = if (self.aspect - 1.).abs() > f64::EPSILON { - let angle = (end - start).to_angle(); + let major_vec = end - start; + let angle = major_vec.y.atan2(major_vec.x); let squash = DAffine2::from_translation(start) * DAffine2::from_angle(angle) * DAffine2::from_scale(DVec2::new(1., self.aspect)) * DAffine2::from_angle(-angle) * DAffine2::from_translation(-start); - + squash * gradient_transform_raw } else { gradient_transform_raw diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index f443df22e8..e9f7f339bc 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1177,7 +1177,8 @@ impl Render for Table { let mut brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); if gradient.gradient_type == GradientType::Radial && (gradient.aspect - 1.).abs() > f64::EPSILON { - let angle = (end - start).to_angle(); + let major_vec = end - start; + let angle = major_vec.y.atan2(major_vec.x); let center = kurbo::Vec2::new(start.x, start.y); let ellipse_affine = kurbo::Affine::translate(center) @@ -1185,7 +1186,7 @@ impl Render for Table { * kurbo::Affine::scale_non_uniform(1., gradient.aspect) * kurbo::Affine::rotate(-angle) * kurbo::Affine::translate(-center); - + brush_transform = ellipse_affine * brush_transform; } From 75ff4e0d0e165b9aaafa67f9b5669cac69b37c8a Mon Sep 17 00:00:00 2001 From: afrdbaig7 Date: Sun, 26 Apr 2026 14:47:43 +0530 Subject: [PATCH 3/3] Fix gradient scope and missing aspect initializers --- .../graph_operation_message_handler.rs | 2 + .../tool/tool_messages/gradient_tool.rs | 53 +++++++++---------- 2 files changed, 28 insertions(+), 27 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 db6148934c..9c52c9cd3d 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 @@ -798,6 +798,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b gradient_type, stops, spread_method, + aspect: 1., }) } usvg::Paint::RadialGradient(radial) => { @@ -828,6 +829,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b gradient_type, stops, spread_method, + aspect: 1., }) } 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 844f545d79..15e68b0f69 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -846,33 +846,32 @@ impl Fsm for GradientToolFsmState { Some(1.), ); } - } - - if gradient.gradient_type == GradientType::Radial { - let major_vec = end - start; - let major_len = major_vec.length(); - if major_len > f64::EPSILON { - let minor_len = major_len * gradient.aspect; - let major_dir = major_vec / major_len; - let minor_dir = major_dir.perp(); - let center = start; - - let minor_pos_vp = center + minor_dir * minor_len; - let minor_neg_vp = center - minor_dir * minor_len; - - let angle = major_dir.y.atan2(major_dir.x); - overlay_context.dashed_ellipse(center, major_len, minor_len, Some(angle), None, None, None, None, Some(COLOR_OVERLAY_BLUE), Some(4.), Some(4.), None); - - overlay_context.line(center, minor_pos_vp, Some(COLOR_OVERLAY_BLUE), None); - overlay_context.line(center, minor_neg_vp, Some(COLOR_OVERLAY_BLUE), None); - - let minor_tol_sq = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); - let pos_active = dragging == Some(GradientDragTarget::RadialMinorPos); - let neg_active = dragging == Some(GradientDragTarget::RadialMinorNeg); - let pos_hovered = !pos_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_pos_vp.distance_squared(mouse) < minor_tol_sq; - let neg_hovered = !neg_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_neg_vp.distance_squared(mouse) < minor_tol_sq; - overlay_context.manipulator_handle(minor_pos_vp, pos_active || pos_hovered, None); - overlay_context.manipulator_handle(minor_neg_vp, neg_active || neg_hovered, None); + if gradient.gradient_type == GradientType::Radial { + let major_vec = end - start; + let major_len = major_vec.length(); + if major_len > f64::EPSILON { + let minor_len = major_len * gradient.aspect; + let major_dir = major_vec / major_len; + let minor_dir = major_dir.perp(); + let center = start; + + let minor_pos_vp = center + minor_dir * minor_len; + let minor_neg_vp = center - minor_dir * minor_len; + + let angle = major_dir.y.atan2(major_dir.x); + overlay_context.dashed_ellipse(center, major_len, minor_len, Some(angle), None, None, None, None, Some(COLOR_OVERLAY_BLUE), Some(4.), Some(4.), None); + + overlay_context.line(center, minor_pos_vp, Some(COLOR_OVERLAY_BLUE), None); + overlay_context.line(center, minor_neg_vp, Some(COLOR_OVERLAY_BLUE), None); + + let minor_tol_sq = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + let pos_active = dragging == Some(GradientDragTarget::RadialMinorPos); + let neg_active = dragging == Some(GradientDragTarget::RadialMinorNeg); + let pos_hovered = !pos_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_pos_vp.distance_squared(mouse) < minor_tol_sq; + let neg_hovered = !neg_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_neg_vp.distance_squared(mouse) < minor_tol_sq; + overlay_context.manipulator_handle(minor_pos_vp, pos_active || pos_hovered, None); + overlay_context.manipulator_handle(minor_neg_vp, neg_active || neg_hovered, None); + } } }