Skip to content

Commit 902c3ed

Browse files
committed
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
1 parent 7bb01c9 commit 902c3ed

4 files changed

Lines changed: 174 additions & 11 deletions

File tree

editor/src/messages/tool/tool_messages/gradient_tool.rs

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ pub enum GradientDragTarget {
294294
Stop(usize),
295295
Midpoint(usize),
296296
New,
297+
/// Drag the +minor-axis handle (perpendicular to major axis, toward +perp direction)
298+
RadialMinorPos,
299+
/// Drag the −minor-axis handle (perpendicular to major axis, toward −perp direction)
300+
RadialMinorNeg,
297301
}
298302

299303
/// Contains information about the selected gradient handle
@@ -341,7 +345,17 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D
341345
return Some(projection);
342346
}
343347

344-
None
348+
/// Compute minor-axis handle positions in document space for a radial gradient.
349+
fn radial_minor_handles(gradient: &Gradient) -> Option<(DVec2, DVec2)> {
350+
let major_vec = gradient.end - gradient.start;
351+
let major_len = major_vec.length();
352+
if major_len < f64::EPSILON {
353+
return None;
354+
}
355+
let minor_len = major_len * gradient.aspect;
356+
let minor_dir = (major_vec / major_len).perp();
357+
let center = gradient.start;
358+
Some((center + minor_dir * minor_len, center - minor_dir * minor_len))
345359
}
346360

347361
impl SelectedGradient {
@@ -561,6 +575,28 @@ impl SelectedGradient {
561575
self.gradient.stops.midpoint[midpoint_index] = midpoint_ratio;
562576
}
563577
}
578+
GradientDragTarget::RadialMinorPos | GradientDragTarget::RadialMinorNeg => {
579+
let document_to_viewport = snap_data.document.metadata().document_to_viewport;
580+
let mouse_doc = document_to_viewport.inverse().transform_point2(mouse);
581+
582+
let center_doc = self.gradient.start;
583+
let major_vec = self.gradient.end - center_doc;
584+
let major_len = major_vec.length();
585+
586+
if major_len < f64::EPSILON {
587+
self.render_gradient(responses);
588+
return;
589+
}
590+
591+
let minor_dir = (major_vec / major_len).perp();
592+
let minor_dist = (mouse_doc - center_doc).dot(minor_dir).abs();
593+
594+
if snap_rotate {
595+
self.gradient.aspect = 1.;
596+
} else {
597+
self.gradient.aspect = (minor_dist / major_len).clamp(0.01, 10.);
598+
}
599+
}
564600
}
565601
self.render_gradient(responses);
566602
}
@@ -810,6 +846,34 @@ impl Fsm for GradientToolFsmState {
810846
}
811847
}
812848

849+
if gradient.gradient_type == GradientType::Radial {
850+
let major_vec = end - start;
851+
let major_len = major_vec.length();
852+
if major_len > f64::EPSILON {
853+
let minor_len = major_len * gradient.aspect;
854+
let major_dir = major_vec / major_len;
855+
let minor_dir = major_dir.perp();
856+
let center = start;
857+
858+
let minor_pos_vp = center + minor_dir * minor_len;
859+
let minor_neg_vp = center - minor_dir * minor_len;
860+
861+
let angle = major_dir.y.atan2(major_dir.x);
862+
overlay_context.dashed_ellipse(center, major_len, minor_len, Some(angle), None, None, None, None, Some(COLOR_OVERLAY_BLUE), Some(4.), Some(4.), None);
863+
864+
overlay_context.line(center, minor_pos_vp, Some(COLOR_OVERLAY_BLUE), None);
865+
overlay_context.line(center, minor_neg_vp, Some(COLOR_OVERLAY_BLUE), None);
866+
867+
let minor_tol_sq = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2);
868+
let pos_active = dragging == Some(GradientDragTarget::RadialMinorPos);
869+
let neg_active = dragging == Some(GradientDragTarget::RadialMinorNeg);
870+
let pos_hovered = !pos_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_pos_vp.distance_squared(mouse) < minor_tol_sq;
871+
let neg_hovered = !neg_active && !matches!(self, GradientToolFsmState::Drawing { .. }) && minor_neg_vp.distance_squared(mouse) < minor_tol_sq;
872+
overlay_context.manipulator_handle(minor_pos_vp, pos_active || pos_hovered, None);
873+
overlay_context.manipulator_handle(minor_neg_vp, neg_active || neg_hovered, None);
874+
}
875+
}
876+
813877
let snap_data = SnapData::new(document, input, viewport);
814878
tool_data.snap_manager.draw_overlays(snap_data, &mut overlay_context);
815879

@@ -1048,6 +1112,33 @@ impl Fsm for GradientToolFsmState {
10481112
let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue };
10491113
let transform = gradient_space_transform(layer, document);
10501114

1115+
if drag_hint.is_none() && gradient.gradient_type == GradientType::Radial {
1116+
if let Some((minor_pos_doc, minor_neg_doc)) = radial_minor_handles(&gradient) {
1117+
let minor_pos_vp = transform.transform_point2(minor_pos_doc);
1118+
let minor_neg_vp = transform.transform_point2(minor_neg_doc);
1119+
let minor_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2);
1120+
1121+
let minor_drag_target = if minor_pos_vp.distance_squared(mouse) < minor_tolerance {
1122+
Some(GradientDragTarget::RadialMinorPos)
1123+
} else if minor_neg_vp.distance_squared(mouse) < minor_tolerance {
1124+
Some(GradientDragTarget::RadialMinorNeg)
1125+
} else {
1126+
None
1127+
};
1128+
1129+
if let Some(drag_target) = minor_drag_target {
1130+
drag_hint = Some(GradientDragHintState::RadialMinor);
1131+
tool_data.selected_gradient = Some(SelectedGradient {
1132+
layer: Some(layer),
1133+
transform,
1134+
gradient: gradient.clone(),
1135+
dragging: drag_target,
1136+
initial_gradient: gradient.clone(),
1137+
});
1138+
}
1139+
}
1140+
}
1141+
10511142
// Check for dragging a midpoint diamond
10521143
if drag_hint.is_none() {
10531144
let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end));
@@ -1380,6 +1471,12 @@ impl Fsm for GradientToolFsmState {
13801471
groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Reset Midpoint")]));
13811472
}
13821473
}
1474+
GradientHoverTarget::RadialMinor => {
1475+
groups.push(HintGroup(vec![
1476+
HintInfo::mouse(MouseMotion::LmbDrag, "Adjust Ellipse"),
1477+
HintInfo::keys([Key::Shift], "Snap to Circle").prepend_plus(),
1478+
]));
1479+
}
13831480
}
13841481

13851482
// Delete/reset hint based on selection
@@ -1414,6 +1511,9 @@ impl Fsm for GradientToolFsmState {
14141511
GradientDragHintState::Midpoint { resettable: true } => {
14151512
groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Reset Midpoint")]));
14161513
}
1514+
GradientDragHintState::RadialMinor => {
1515+
groups.push(HintGroup(vec![HintInfo::keys([Key::Shift], "Snap to Circle")]));
1516+
}
14171517
_ => {}
14181518
}
14191519

@@ -1449,6 +1549,17 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi
14491549
let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end));
14501550
let line_length = start.distance(end);
14511551

1552+
if gradient.gradient_type == GradientType::Radial {
1553+
if let Some((minor_pos_doc, minor_neg_doc)) = radial_minor_handles(&gradient) {
1554+
let minor_pos_vp = transform.transform_point2(minor_pos_doc);
1555+
let minor_neg_vp = transform.transform_point2(minor_neg_doc);
1556+
let minor_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2);
1557+
if minor_pos_vp.distance_squared(mouse) < minor_tolerance || minor_neg_vp.distance_squared(mouse) < minor_tolerance {
1558+
return GradientHoverTarget::RadialMinor;
1559+
}
1560+
}
1561+
}
1562+
14521563
// Check midpoint diamonds first (smaller hit area, higher priority)
14531564
for i in 0..gradient.stops.position.len().saturating_sub(1) {
14541565
let left = gradient.stops.position[i];
@@ -1506,7 +1617,7 @@ fn compute_selected_target(tool_data: &GradientToolData) -> GradientSelectedTarg
15061617
let resettable = selected_gradient.gradient.stops.midpoint.get(i).is_some_and(|&midpoint_value| midpoint_is_resettable(midpoint_value));
15071618
GradientSelectedTarget::Midpoint { resettable }
15081619
}
1509-
GradientDragTarget::New => GradientSelectedTarget::None,
1620+
GradientDragTarget::New | GradientDragTarget::RadialMinorPos | GradientDragTarget::RadialMinorNeg => GradientSelectedTarget::None,
15101621
}
15111622
}
15121623

@@ -1593,6 +1704,8 @@ enum GradientHoverTarget {
15931704
Midpoint {
15941705
resettable: bool,
15951706
},
1707+
/// Hovering over a radial minor-axis handle
1708+
RadialMinor,
15961709
}
15971710

15981711
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -1615,6 +1728,8 @@ enum GradientDragHintState {
16151728
Midpoint {
16161729
resettable: bool,
16171730
},
1731+
/// Dragging a radial minor-axis handle to reshape the ellipse
1732+
RadialMinor,
16181733
}
16191734

16201735
#[cfg(test)]

node-graph/libraries/rendering/src/render_ext.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,11 @@ impl RenderExt for Gradient {
3636
let start = transform_points.transform_point2(self.start);
3737
let end = transform_points.transform_point2(self.end);
3838

39-
let gradient_transform = if transformed_bounds.matrix2.determinant() != 0. {
39+
let gradient_transform_raw = if transformed_bounds.matrix2.determinant() != 0. {
4040
transformed_bounds.inverse()
4141
} else {
4242
DAffine2::IDENTITY // Ignore if the transform cannot be inverted (the bounds are zero). See issue #1944.
4343
};
44-
let gradient_transform = format_transform_matrix(gradient_transform);
45-
let gradient_transform = if gradient_transform.is_empty() {
46-
String::new()
47-
} else {
48-
format!(r#" gradientTransform="{gradient_transform}""#)
49-
};
5044

5145
let spread_method = if self.spread_method == GradientSpreadMethod::Pad {
5246
String::new()
@@ -58,14 +52,41 @@ impl RenderExt for Gradient {
5852

5953
match self.gradient_type {
6054
GradientType::Linear => {
55+
let gradient_transform = format_transform_matrix(gradient_transform_raw);
56+
let gradient_transform = if gradient_transform.is_empty() {
57+
String::new()
58+
} else {
59+
format!(r#" gradientTransform="{gradient_transform}""#)
60+
};
6161
let _ = write!(
6262
svg_defs,
6363
r#"<linearGradient id="{}" x1="{}" y1="{}" x2="{}" y2="{}"{spread_method}{gradient_transform}>{}</linearGradient>"#,
6464
gradient_id, start.x, start.y, end.x, end.y, stop
6565
);
6666
}
6767
GradientType::Radial => {
68-
let radius = (f64::powi(start.x - end.x, 2) + f64::powi(start.y - end.y, 2)).sqrt();
68+
let radius = start.distance(end);
69+
70+
let ellipse_transform = if (self.aspect - 1.).abs() > f64::EPSILON {
71+
let angle = (end - start).to_angle();
72+
let squash = DAffine2::from_translation(start)
73+
* DAffine2::from_angle(angle)
74+
* DAffine2::from_scale(DVec2::new(1., self.aspect))
75+
* DAffine2::from_angle(-angle)
76+
* DAffine2::from_translation(-start);
77+
78+
squash * gradient_transform_raw
79+
} else {
80+
gradient_transform_raw
81+
};
82+
83+
let gradient_transform = format_transform_matrix(ellipse_transform);
84+
let gradient_transform = if gradient_transform.is_empty() {
85+
String::new()
86+
} else {
87+
format!(r#" gradientTransform="{gradient_transform}""#)
88+
};
89+
6990
let _ = write!(
7091
svg_defs,
7192
r#"<radialGradient id="{}" cx="{}" cy="{}" r="{}"{spread_method}{gradient_transform}>{}</radialGradient>"#,

node-graph/libraries/rendering/src/renderer.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,21 @@ impl Render for Table<Vector> {
11741174
} else {
11751175
Default::default()
11761176
};
1177-
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
1177+
let mut brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
1178+
1179+
if gradient.gradient_type == GradientType::Radial && (gradient.aspect - 1.).abs() > f64::EPSILON {
1180+
let angle = (end - start).to_angle();
1181+
let center = kurbo::Vec2::new(start.x, start.y);
1182+
1183+
let ellipse_affine = kurbo::Affine::translate(center)
1184+
* kurbo::Affine::rotate(angle)
1185+
* kurbo::Affine::scale_non_uniform(1., gradient.aspect)
1186+
* kurbo::Affine::rotate(-angle)
1187+
* kurbo::Affine::translate(-center);
1188+
1189+
brush_transform = ellipse_affine * brush_transform;
1190+
}
1191+
11781192
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path);
11791193
}
11801194
Fill::None => {}

node-graph/libraries/vector-types/src/gradient.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,14 @@ pub struct Gradient {
368368
pub end: DVec2,
369369
#[serde(default)]
370370
pub spread_method: GradientSpreadMethod,
371+
/// Ratio of the minor radius to the major radius for radial gradients (1.0 = circle).
372+
/// Ignored for linear gradients. Defaults to 1.0 so old documents deserialize as circles.
373+
#[serde(default = "default_aspect")]
374+
pub aspect: f64,
375+
}
376+
377+
fn default_aspect() -> f64 {
378+
1.
371379
}
372380

373381
impl Default for Gradient {
@@ -378,6 +386,7 @@ impl Default for Gradient {
378386
start: DVec2::new(0., 0.5),
379387
end: DVec2::new(1., 0.5),
380388
spread_method: GradientSpreadMethod::Pad,
389+
aspect: 1.,
381390
}
382391
}
383392
}
@@ -390,6 +399,7 @@ impl std::hash::Hash for Gradient {
390399
.chain(self.end.to_array().iter())
391400
.chain(self.stops.position.iter())
392401
.chain(self.stops.midpoint.iter())
402+
.chain(std::iter::once(&self.aspect))
393403
.for_each(|x| x.to_bits().hash(state));
394404
self.stops.color.iter().for_each(|color| color.hash(state));
395405
self.gradient_type.hash(state);
@@ -432,6 +442,7 @@ impl Gradient {
432442
stops,
433443
gradient_type,
434444
spread_method,
445+
aspect: 1.,
435446
}
436447
}
437448

@@ -446,13 +457,15 @@ impl Gradient {
446457
let stops = GradientStops::new(stops);
447458
let gradient_type = if time < 0.5 { self.gradient_type } else { other.gradient_type };
448459
let spread_method = if time < 0.5 { self.spread_method } else { other.spread_method };
460+
let aspect = self.aspect + (other.aspect - self.aspect) * time;
449461

450462
Self {
451463
start,
452464
end,
453465
stops,
454466
gradient_type,
455467
spread_method,
468+
aspect,
456469
}
457470
}
458471

0 commit comments

Comments
 (0)