From faed7f81bf8bb8e1b7a2ffef539fdeaf3d0551a9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 5 Aug 2026 17:47:54 -0700 Subject: [PATCH] Give whole-expanse gradient layers a transform cage based on the gradient line (#4413) --- .../tool/tool_messages/gradient_tool.rs | 47 ++++++++++++ .../libraries/rendering/src/renderer.rs | 74 +++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index c415558161..0e54af7586 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -2977,6 +2977,53 @@ mod test_gradient { ); } + #[tokio::test] + async fn whole_expanse_gradient_reports_control_geometry_outline_bounds() { + use graph_craft::document::NodeId; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor + .handle_message(GraphOperationMessage::NewCustomLayer { + id: NodeId::new(), + nodes: Vec::new(), + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }) + .await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + // A gradient dragged across a blank layer paints its whole expanse via a 'Gradient Value' chain + editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; + assert!(get_upstream_gradient_value_node_id(layer, &editor.active_document().network_interface).is_some()); + + editor.eval_graph().await.expect("graph should evaluate"); + let bounds = editor.active_document().metadata().bounding_box_with_transform(layer, DAffine2::IDENTITY); + assert_eq!(bounds, Some([DVec2::ZERO, DVec2::X]), "a linear expanse's local bounds should be its canonical gradient line"); + + // A linear's control line has no interior, so it registers as outline only + let click_targets = editor.active_document().metadata().click_targets(layer); + assert!(click_targets.is_none_or(|targets| targets.is_empty()), "a linear expanse should gain no click targets"); + + // Switching the form swaps the control geometry to the radial unit circle + editor + .handle_message(GradientToolMessage::UpdateOptions { + options: super::GradientOptionsUpdate::Form(GradientForm::Radial), + }) + .await; + editor.eval_graph().await.expect("graph should evaluate"); + let bounds = editor.active_document().metadata().bounding_box_with_transform(layer, DAffine2::IDENTITY); + assert_eq!(bounds, Some([DVec2::splat(-1.), DVec2::splat(1.)]), "a radial expanse's local bounds should be its unit circle"); + + // A radial's main ellipse doubles as the layer's draggable handle regardless of spread + let click_targets = editor.active_document().metadata().click_targets(layer).expect("a radial expanse should be clickable"); + assert!( + click_targets.iter().any(|target| target.intersect_point_no_stroke(DVec2::ZERO)), + "the ellipse interior should register a hit" + ); + } + #[tokio::test] async fn chain_stop_placement_composes_through_setter_nodes() { use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 30775ad626..f0cfae6df6 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2158,6 +2158,19 @@ impl Render for List { } } +/// A gradient's control geometry in its local space: the unit circle a radial gradient's transform carries to its drawn ellipse, or the (0,0) to (1,0) gradient line for a linear one. +fn gradient_control_outline(gradient_form: GradientForm) -> Subpath { + match gradient_form { + GradientForm::Linear => Subpath::new_line(DVec2::ZERO, DVec2::X), + GradientForm::Radial => Subpath::new_ellipse(DVec2::splat(-1.), DVec2::splat(1.)), + } +} + +/// Whether the control geometry's interior is a draggable click area: a radial's main ellipse acts as the layer's handle regardless of spread, while a linear's control line has no interior. +fn gradient_control_interior_is_clickable(gradient_form: GradientForm) -> bool { + gradient_form == GradientForm::Radial +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { // For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. @@ -2332,6 +2345,67 @@ impl Render for List { } } } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option) { + let Some(element_id) = element_id else { return }; + if self.is_empty() { + return; + } + + // Targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` + let item_zero_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let item_zero_inverse = if transform_is_invertible(item_zero_transform) { + item_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let mut outline_targets = Vec::new(); + let mut click_targets = Vec::new(); + for index in 0..self.len() { + let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(item_zero_inverse * item_transform); + let target = Arc::new(target); + + if gradient_control_interior_is_clickable(gradient_form) { + click_targets.push(target.clone()); + } + outline_targets.push(target); + } + + metadata.outlines.insert(element_id, outline_targets); + if !click_targets.is_empty() { + metadata.click_targets.insert(element_id, click_targets); + } + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec) { + for index in 0..self.len() { + let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); + if !gradient_control_interior_is_clickable(gradient_form) { + continue; + } + + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(transform); + click_targets.push(target); + } + } + + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + for index in 0..self.len() { + let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(transform); + outlines.push(target); + } + } } /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`).