From 171d47b5866f30614fcb25e226ba3a7d7440a2dd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 7 May 2026 01:42:22 -0700 Subject: [PATCH 01/24] Heart node --- .../nodes/vector/src/generator_nodes.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index a77b5c98eb..98ff556db8 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -172,6 +172,110 @@ fn regular_polygon( Item::new_from_element(Vector::from_bezpath(shapes::regular_polygon_bezpath(DVec2::ZERO, points, *radius.element()))) } +/// Generates a heart shape with parametric control over the cleavage, lobes, shoulders, and bottom point. +#[node_macro::node(category("Vector: Shape"))] +fn heart( + _: impl Ctx, + _primary: (), + #[unit(" px")] + #[default(50)] + radius: f64, + /// How far the top V dips below the upper bound of the heart. + #[default(0.2)] + #[range((0., 0.6))] + #[hard_min(0.)] + #[hard_max(0.6)] + cleavage_depth: f64, + /// Half-angle of the top V. Zero collapses the V into a smooth join. + #[default(45.)] + #[range((0., 89.))] + #[hard_min(0.)] + #[hard_max(89.)] + cleavage_angle: Angle, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + #[default(0.55)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + lobe_fullness: f64, + /// Vertical position of the side anchor (positive raises the shoulder). + #[default(0.5)] + #[range((-0.5, 0.9))] + #[hard_min(-0.5)] + #[hard_max(0.9)] + shoulder_height: f64, + /// Horizontal position of the side anchor. + #[default(1.)] + #[range((0., 1.4))] + #[hard_min(0.)] + #[hard_max(1.4)] + shoulder_width: f64, + /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. + #[default(0.)] + #[range((-60., 60.))] + #[hard_min(-60.)] + #[hard_max(60.)] + shoulder_tilt: Angle, + /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. + #[default(0.55)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + upper_curvature: f64, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + #[default(1.)] + #[range((0., 1.5))] + #[hard_min(0.)] + #[hard_max(1.5)] + lower_curvature: f64, + /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. + #[default(30.)] + #[range((0., 89.))] + #[hard_min(0.)] + #[hard_max(89.)] + point_sharpness: Angle, + /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. + #[default(0.7)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + taper_length: f64, +) -> Table { + let cleavage_angle = cleavage_angle.to_radians(); + let point_sharpness = point_sharpness.to_radians(); + let shoulder_tilt = shoulder_tilt.to_radians(); + + // Anchor points for the right half plus the y-axis cusps, in normalized coordinates (y points downward). + let top = DVec2::new(0., -1. + cleavage_depth); + let shoulder = DVec2::new(shoulder_width, -shoulder_height); + let bottom = DVec2::new(0., 1.); + + // Unit tangent directions, all measured from the upward vertical. + let top_dir = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); + let bottom_dir_out = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); + let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); + let shoulder_down = -shoulder_up; + + // Cubic Bezier control points for the right half. + let c1 = top + top_dir * lobe_fullness; + let c2 = shoulder + shoulder_up * upper_curvature; + let c3 = shoulder + shoulder_down * lower_curvature; + let c4 = bottom + bottom_dir_out * taper_length; + + let mirror = |p: DVec2| DVec2::new(-p.x, p.y); + + // Closed clockwise path: T → S → B → S' → T. Joins at T and B are sharp; joins at the shoulders are G1. + let manipulator_groups = [ + subpath::ManipulatorGroup::new(top * radius, Some(mirror(c1) * radius), Some(c1 * radius)), + subpath::ManipulatorGroup::new(shoulder * radius, Some(c2 * radius), Some(c3 * radius)), + subpath::ManipulatorGroup::new(bottom * radius, Some(c4 * radius), Some(mirror(c4) * radius)), + subpath::ManipulatorGroup::new(mirror(shoulder) * radius, Some(mirror(c3) * radius), Some(mirror(c2) * radius)), + ] + .to_vec(); + + Table::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) +} + /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. #[node_macro::node(category("Vector: Shape"))] fn star( From 1cb9cb4226a7ccd13a95319d3df9bc3e2d424cbf Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 25 May 2026 15:06:23 +0530 Subject: [PATCH 02/24] Add Heart drawing mode to the Shape tool with gizmo registration --- .../gizmos/gizmo_manager.rs | 15 +++ .../graph_modification_utils.rs | 4 + .../shapes/heart_shape.rs | 113 ++++++++++++++++++ .../tool/common_functionality/shapes/mod.rs | 1 + .../shapes/shape_utility.rs | 8 +- .../messages/tool/tool_messages/shape_tool.rs | 25 +++- .../nodes/vector/src/generator_nodes.rs | 4 +- 7 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 editor/src/messages/tool/common_functionality/shapes/heart_shape.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 962e9a5d06..3277dbfebb 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -8,6 +8,7 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::circle_shape::CircleGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; +use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; use crate::messages::tool::common_functionality::shapes::polygon_shape::PolygonGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; @@ -32,6 +33,7 @@ pub enum ShapeGizmoHandlers { Circle(CircleGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), + Heart(HeartGizmoHandler), } impl ShapeGizmoHandlers { @@ -45,6 +47,7 @@ impl ShapeGizmoHandlers { Self::Circle(_) => "circle", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", + Self::Heart(_) => "heart", Self::None => "none", } } @@ -58,6 +61,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), + Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -71,6 +75,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), + Self::Heart(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -84,6 +89,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), + Self::Heart(h) => h.handle_click(), Self::None => {} } } @@ -97,6 +103,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), + Self::Heart(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -110,6 +117,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), + Self::Heart(h) => h.cleanup(), Self::None => {} } } @@ -131,6 +139,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), + Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -151,6 +160,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), + Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -163,6 +173,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), + Self::Heart(h) => h.mouse_cursor_icon(), Self::None => None, } } @@ -214,6 +225,10 @@ impl GizmoManager { if graph_modification_utils::get_spiral_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Spiral(SpiralGizmoHandler::default())); } + // Heart + if graph_modification_utils::get_heart_id(layer, &document.network_interface).is_some() { + return Some(ShapeGizmoHandlers::Heart(HeartGizmoHandler::default())); + } None } 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 67f5a0e6cb..ca9791d785 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -587,6 +587,10 @@ pub fn get_text_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkIn NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) } +pub fn get_heart_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::heart::IDENTIFIER)) +} + pub fn get_grid_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::grid::IDENTIFIER)) } diff --git a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs new file mode 100644 index 0000000000..2fc5d4fffd --- /dev/null +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -0,0 +1,113 @@ +use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::message::Message; +use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; +use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler}; +use crate::messages::tool::common_functionality::graph_modification_utils; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, ShapeToolModifierKey}; +use crate::messages::tool::tool_messages::shape_tool::ShapeToolData; +use crate::messages::tool::tool_messages::tool_prelude::*; +use glam::DAffine2; +use graph_craft::document::NodeInput; +use graph_craft::document::value::TaggedValue; +use std::collections::VecDeque; + +/// Placeholder gizmo handler for the Heart shape. +/// The heart's parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. +#[derive(Clone, Debug, Default)] +pub struct HeartGizmoHandler; + +impl ShapeGizmoHandler for HeartGizmoHandler { + fn is_any_gizmo_hovered(&self) -> bool { + false + } + + fn handle_state(&mut self, _layer: LayerNodeIdentifier, _mouse_position: DVec2, _document: &DocumentMessageHandler, _responses: &mut VecDeque) {} + + fn handle_click(&mut self) {} + + fn handle_update(&mut self, _drag_start: DVec2, _document: &DocumentMessageHandler, _input: &InputPreprocessorMessageHandler, _responses: &mut VecDeque) {} + + fn overlays( + &self, + _document: &DocumentMessageHandler, + _selected_layer: Option, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + _mouse_position: DVec2, + _overlay_context: &mut OverlayContext, + ) { + } + + fn dragging_overlays( + &self, + _document: &DocumentMessageHandler, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + _mouse_position: DVec2, + _overlay_context: &mut OverlayContext, + ) { + } + + fn cleanup(&mut self) {} + + fn mouse_cursor_icon(&self) -> Option { + None + } +} + +#[derive(Default)] +pub struct Heart; + +impl Heart { + pub fn create_node() -> NodeTemplate { + let node_type = resolve_proto_node_type(graphene_std::vector::generator_nodes::heart::IDENTIFIER).expect("Heart node can't be found"); + node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(0.), false))]) + } + + pub fn update_shape( + document: &DocumentMessageHandler, + ipp: &InputPreprocessorMessageHandler, + viewport: &ViewportMessageHandler, + layer: LayerNodeIdentifier, + shape_tool_data: &mut ShapeToolData, + modifier: ShapeToolModifierKey, + responses: &mut VecDeque, + ) { + let [center, lock_ratio, _] = modifier; + + if let Some([start, end]) = shape_tool_data.data.calculate_points(document, ipp, viewport, center, lock_ratio) { + let Some(node_id) = graph_modification_utils::get_heart_id(layer, &document.network_interface) else { + return; + }; + + let dimensions = (start - end).abs(); + + let mut scale = DVec2::ONE; + let radius: f64; + if dimensions.x > dimensions.y { + scale.x = dimensions.x / dimensions.y; + radius = dimensions.y / 2.; + } else { + scale.y = dimensions.y / dimensions.x; + radius = dimensions.x / 2.; + } + + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(node_id, 1), + input: NodeInput::value(TaggedValue::F64(radius), false), + }); + + responses.add(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale_angle_translation(scale, 0., (start + end) / 2.), + transform_in: TransformIn::Viewport, + skip_rerender: false, + }); + } + } +} diff --git a/editor/src/messages/tool/common_functionality/shapes/mod.rs b/editor/src/messages/tool/common_functionality/shapes/mod.rs index 4d74b15ba5..74036abf9f 100644 --- a/editor/src/messages/tool/common_functionality/shapes/mod.rs +++ b/editor/src/messages/tool/common_functionality/shapes/mod.rs @@ -3,6 +3,7 @@ pub mod arrow_shape; pub mod circle_shape; pub mod ellipse_shape; pub mod grid_shape; +pub mod heart_shape; pub mod line_shape; pub mod polygon_shape; pub mod rectangle_shape; diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index 0558adcab9..ff61c0d29a 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -35,6 +35,7 @@ pub enum ShapeType { Spiral, Grid, Arrow, + Heart, Line, // KEEP THIS AT THE END Rectangle, // KEEP THIS AT THE END Ellipse, // KEEP THIS AT THE END @@ -50,6 +51,7 @@ impl ShapeType { ShapeType::Spiral, ShapeType::Grid, ShapeType::Arrow, + ShapeType::Heart, ShapeType::Line, // KEEP THIS AT THE END ShapeType::Rectangle, // KEEP THIS AT THE END ShapeType::Ellipse, // KEEP THIS AT THE END @@ -58,7 +60,10 @@ impl ShapeType { /// True if this shape mode's fill checkbox is ticked by default when nothing is selected. /// Spiral/Grid/Line are open paths and default to fill-off, the closed shapes default to fill-on. pub fn defaults_to_fill(&self) -> bool { - matches!(self, Self::Polygon | Self::Star | Self::Circle | Self::Arc | Self::Rectangle | Self::Ellipse | Self::Arrow) + matches!( + self, + Self::Polygon | Self::Star | Self::Circle | Self::Arc | Self::Rectangle | Self::Ellipse | Self::Arrow | Self::Heart + ) } pub fn name(&self) -> String { @@ -70,6 +75,7 @@ impl ShapeType { Self::Spiral => "Spiral", Self::Grid => "Grid", Self::Arrow => "Arrow", + Self::Heart => "Heart", Self::Line => "Line", // KEEP THIS AT THE END Self::Rectangle => "Rectangle", // KEEP THIS AT THE END Self::Ellipse => "Ellipse", // KEEP THIS AT THE END diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index eda939a304..b4e268742a 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -16,6 +16,7 @@ use crate::messages::tool::common_functionality::shapes::arc_shape::Arc; use crate::messages::tool::common_functionality::shapes::arrow_shape::Arrow; use crate::messages::tool::common_functionality::shapes::circle_shape::Circle; use crate::messages::tool::common_functionality::shapes::grid_shape::Grid; +use crate::messages::tool::common_functionality::shapes::heart_shape::Heart; use crate::messages::tool::common_functionality::shapes::line_shape::LineToolData; use crate::messages::tool::common_functionality::shapes::polygon_shape::Polygon; use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeToolModifierKey, ShapeType, anchor_overlays, clicked_on_shape_endpoints, transform_cage_overlays}; @@ -212,6 +213,12 @@ fn create_shape_option_widget(shape_type: ShapeType) -> WidgetInstance { } .into() }), + MenuListEntry::new("Heart").label("Heart").on_commit(move |_| { + ShapeToolMessage::UpdateOptions { + options: ShapeOptionsUpdate::ShapeType(ShapeType::Heart), + } + .into() + }), ]]; DropdownInput::new(entries).selected_index(Some(shape_type as u32)).widget_instance() } @@ -325,6 +332,7 @@ fn sync_shape_options_from_selection(options: &mut ShapeToolOptions, tool_data: (spiral::IDENTIFIER, ShapeType::Spiral), (grid::IDENTIFIER, ShapeType::Grid), (arrow::IDENTIFIER, ShapeType::Arrow), + (heart::IDENTIFIER, ShapeType::Heart), ] .into_iter() .find_map(|(id, shape)| layer_view.upstream_node_id_from_name(&proto(id)).map(|_| shape)) else { @@ -407,7 +415,7 @@ fn sync_shape_options_from_selection(options: &mut ShapeToolOptions, tool_data: changed = true; } } - ShapeType::Ellipse | ShapeType::Rectangle | ShapeType::Line | ShapeType::Circle => {} + ShapeType::Ellipse | ShapeType::Rectangle | ShapeType::Line | ShapeType::Circle | ShapeType::Heart => {} } changed @@ -1088,7 +1096,7 @@ impl Fsm for ShapeToolFsmState { }; match tool_data.current_shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => { + ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse | ShapeType::Heart => { tool_data.data.start(document, input, viewport); } ShapeType::Arrow | ShapeType::Line => { @@ -1111,6 +1119,7 @@ impl Fsm for ShapeToolFsmState { ShapeType::Spiral => Spiral::create_node(tool_options.spiral_type, tool_options.turns), ShapeType::Grid => Grid::create_node(tool_options.grid_type), ShapeType::Arrow => Arrow::create_node(tool_options.arrow_shaft_width, tool_options.arrow_head_width, tool_options.arrow_head_length), + ShapeType::Heart => Heart::create_node(), ShapeType::Line => Line::create_node(), ShapeType::Rectangle => Rectangle::create_node(), ShapeType::Ellipse => Ellipse::create_node(), @@ -1122,7 +1131,7 @@ impl Fsm for ShapeToolFsmState { let defered_responses = &mut VecDeque::new(); match tool_data.current_shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => { + ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse | ShapeType::Heart => { defered_responses.add(GraphOperationMessage::TransformSet { layer, transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position), @@ -1186,6 +1195,7 @@ impl Fsm for ShapeToolFsmState { ShapeType::Spiral => Spiral::update_shape(document, input, viewport, layer, tool_data, responses), ShapeType::Grid => Grid::update_shape(document, input, layer, tool_options.grid_type, tool_data, modifier, responses), ShapeType::Arrow => Arrow::update_shape(document, input, viewport, layer, tool_data, modifier, responses), + ShapeType::Heart => Heart::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Line => Line::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Rectangle => Rectangle::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Ellipse => Ellipse::update_shape(document, input, viewport, layer, tool_data, modifier, responses), @@ -1454,13 +1464,20 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque vec![HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDrag, "Draw Heart"), + HintInfo::keys([Key::Shift], "Constrain Regular").prepend_plus(), + HintInfo::keys([Key::Alt], "From Center").prepend_plus(), + ])], }; HintData(hint_groups) } ShapeToolFsmState::Drawing(shape) => { let mut common_hint_group = vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]; let tool_hint_group = match shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Arc => HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]), + ShapeType::Polygon | ShapeType::Star | ShapeType::Arc | ShapeType::Heart => { + HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]) + } ShapeType::Circle => HintGroup(vec![HintInfo::keys([Key::Alt], "From Center")]), ShapeType::Spiral => HintGroup(vec![]), ShapeType::Grid => HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]), diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 98ff556db8..e585b0425a 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -240,7 +240,7 @@ fn heart( #[hard_min(0.)] #[hard_max(1.2)] taper_length: f64, -) -> Table { +) -> List { let cleavage_angle = cleavage_angle.to_radians(); let point_sharpness = point_sharpness.to_radians(); let shoulder_tilt = shoulder_tilt.to_radians(); @@ -273,7 +273,7 @@ fn heart( ] .to_vec(); - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + List::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. From 127d09586cf81ecc55e88770cd6ed3b1005a27c5 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Fri, 24 Jul 2026 07:42:16 +0530 Subject: [PATCH 03/24] Migrate the Heart node to the ranked node input API --- .../nodes/vector/src/generator_nodes.rs | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index e585b0425a..660b61ecfb 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -179,71 +179,69 @@ fn heart( _primary: (), #[unit(" px")] #[default(50)] - radius: f64, + radius: Item, /// How far the top V dips below the upper bound of the heart. #[default(0.2)] - #[range((0., 0.6))] - #[hard_min(0.)] - #[hard_max(0.6)] - cleavage_depth: f64, + #[range] + #[hard(0..0.6)] + cleavage_depth: Item, /// Half-angle of the top V. Zero collapses the V into a smooth join. #[default(45.)] - #[range((0., 89.))] - #[hard_min(0.)] - #[hard_max(89.)] - cleavage_angle: Angle, + #[range] + #[hard(0..89)] + cleavage_angle: Item, /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. #[default(0.55)] - #[range((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - lobe_fullness: f64, + #[range] + #[hard(0..1.2)] + lobe_fullness: Item, /// Vertical position of the side anchor (positive raises the shoulder). #[default(0.5)] - #[range((-0.5, 0.9))] - #[hard_min(-0.5)] - #[hard_max(0.9)] - shoulder_height: f64, + #[range] + #[hard(-0.5..0.9)] + shoulder_height: Item, /// Horizontal position of the side anchor. #[default(1.)] - #[range((0., 1.4))] - #[hard_min(0.)] - #[hard_max(1.4)] - shoulder_width: f64, + #[range] + #[hard(0..1.4)] + shoulder_width: Item, /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. #[default(0.)] - #[range((-60., 60.))] - #[hard_min(-60.)] - #[hard_max(60.)] - shoulder_tilt: Angle, + #[range] + #[hard(-60..60)] + shoulder_tilt: Item, /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. #[default(0.55)] - #[range((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - upper_curvature: f64, + #[range] + #[hard(0..1.2)] + upper_curvature: Item, /// Tangent length at the shoulder going down, controlling the curvature of the lower side. #[default(1.)] - #[range((0., 1.5))] - #[hard_min(0.)] - #[hard_max(1.5)] - lower_curvature: f64, + #[range] + #[hard(0..1.5)] + lower_curvature: Item, /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. #[default(30.)] - #[range((0., 89.))] - #[hard_min(0.)] - #[hard_max(89.)] - point_sharpness: Angle, + #[range] + #[hard(0..89)] + point_sharpness: Item, /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. #[default(0.7)] - #[range((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - taper_length: f64, -) -> List { - let cleavage_angle = cleavage_angle.to_radians(); - let point_sharpness = point_sharpness.to_radians(); - let shoulder_tilt = shoulder_tilt.to_radians(); + #[range] + #[hard(0..1.2)] + taper_length: Item, +) -> Item { + let radius = *radius.element(); + let cleavage_depth = *cleavage_depth.element(); + let lobe_fullness = *lobe_fullness.element(); + let shoulder_height = *shoulder_height.element(); + let shoulder_width = *shoulder_width.element(); + let upper_curvature = *upper_curvature.element(); + let lower_curvature = *lower_curvature.element(); + let taper_length = *taper_length.element(); + let cleavage_angle = cleavage_angle.element().to_radians(); + let point_sharpness = point_sharpness.element().to_radians(); + let shoulder_tilt = shoulder_tilt.element().to_radians(); // Anchor points for the right half plus the y-axis cusps, in normalized coordinates (y points downward). let top = DVec2::new(0., -1. + cleavage_depth); @@ -273,7 +271,7 @@ fn heart( ] .to_vec(); - List::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + Item::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. From c77531fda274d657c72074749124902ddefbada1 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 6 Jul 2026 23:56:40 +0530 Subject: [PATCH 04/24] Add registry-driven generic gizmo system --- .../generic_gizmos/generic_dial_gizmo.rs | 167 ++++++++++ .../generic_gizmos/generic_slider_gizmo.rs | 193 ++++++++++++ .../gizmos/generic_gizmos/mod.rs | 264 ++++++++++++++++ .../gizmos/gizmo_manager.rs | 13 + .../gizmos/gizmo_registry.rs | 294 ++++++++++++++++++ .../tool/common_functionality/gizmos/mod.rs | 2 + 6 files changed, 933 insertions(+) create mode 100644 editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs create mode 100644 editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs create mode 100644 editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs create mode 100644 editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs new file mode 100644 index 0000000000..e90049562e --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -0,0 +1,167 @@ +//! A generic dial that edits a discrete `u32` node parameter (e.g. a polygon's side count). +//! +//! Like [`GenericSliderGizmo`](super::generic_slider_gizmo::GenericSliderGizmo), this is fully +//! data-driven from the [gizmo registry]: it is anchored at the layer's origin and converts a +//! horizontal drag into integer steps (drag right to increase, left to decrease). +//! +//! [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry + +use crate::consts::NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH; +use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::message::Message; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; +use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_u32_input; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::GizmoInfo; +use glam::DVec2; +use graph_craft::ProtoNodeIdentifier; +use graph_craft::document::NodeId; +use graph_craft::document::NodeInput; +use graph_craft::document::value::TaggedValue; +use std::collections::VecDeque; + +/// Horizontal drag distance (viewport px) that corresponds to one integer step. +const DIAL_PIXELS_PER_STEP: f64 = 20.; +/// Viewport radius of the drawn dial indicator. +const DIAL_INDICATOR_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH; +/// Viewport radius of the clickable hit area. Deliberately larger than the drawn indicator so the +/// handle is easy to grab and the press doesn't fall through to the layer-move behavior. +const DIAL_HOVER_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH + 8.; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GenericDialState { + #[default] + Inactive, + Hover, + Dragging, +} + +/// A rotary dial bound to one `u32` parameter of one node. +#[derive(Clone, Debug)] +pub struct GenericDialGizmo { + layer: LayerNodeIdentifier, + node_id: NodeId, + identifier: ProtoNodeIdentifier, + info: GizmoInfo, + state: GenericDialState, + /// Parameter value captured when the drag began. + initial_value: u32, +} + +impl GenericDialGizmo { + pub fn new(layer: LayerNodeIdentifier, node_id: NodeId, identifier: ProtoNodeIdentifier, info: GizmoInfo) -> Self { + Self { + layer, + node_id, + identifier, + info, + state: GenericDialState::Inactive, + initial_value: 0, + } + } + + pub fn is_hovered(&self) -> bool { + self.state == GenericDialState::Hover + } + + pub fn is_dragging(&self) -> bool { + self.state == GenericDialState::Dragging + } + + pub fn cleanup(&mut self) { + self.state = GenericDialState::Inactive; + } + + pub fn handle_click(&mut self) { + if self.state == GenericDialState::Hover { + self.state = GenericDialState::Dragging; + } + } + + fn current_value(&self, document: &DocumentMessageHandler) -> Option { + read_u32_input(self.layer, document, &self.identifier, self.info.parameter_index) + } + + /// Hover detection: the dial occupies a disc of `DIAL_INDICATOR_RADIUS` around the layer origin. + /// Pure hover test: returns the mouse's distance to the dial center when it is a hover + /// candidate, or `None` otherwise. Used by the manager to resolve overlap priority. Performs + /// no state mutation. + pub fn hover_distance(&self, mouse_position: DVec2, document: &DocumentMessageHandler) -> Option { + self.current_value(document)?; + + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + // Hide the dial when the shape is degenerate on screen. + let extent = viewport.transform_point2(DVec2::new(1., 0.)).distance(center); + if extent < f64::EPSILON { + return None; + } + + let distance = mouse_position.distance(center); + (distance <= DIAL_HOVER_RADIUS).then_some(distance) + } + + /// Transition into the hovered state (no-op if already hovered or dragging), capturing the + /// reference value because `handle_click` has no document access. + pub fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + if self.state != GenericDialState::Inactive { + return; + } + let Some(value) = self.current_value(document) else { return }; + + self.state = GenericDialState::Hover; + self.initial_value = value; + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); + } + + /// Transition out of the hovered state. Leaves an in-progress drag untouched. + pub fn exit_hover(&mut self, responses: &mut VecDeque) { + if self.state == GenericDialState::Hover { + self.state = GenericDialState::Inactive; + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); + } + } + + /// Convert the drag into integer steps. The magnitude comes from the total drag distance (so the + /// dial responds to motion in any direction, not just horizontal), while the horizontal direction + /// decides the sign: drag right to increase, left to decrease. Clamped to the registry's bounds. + pub fn handle_update(&self, drag_start: DVec2, _document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + let drag = input.mouse.position - drag_start; + let direction = (input.mouse.position.x - drag_start.x).signum(); + let steps = ((drag.length() / DIAL_PIXELS_PER_STEP).round() * direction) as i64; + + let min = self.info.min.map(|m| m as i64).unwrap_or(0); + let max = self.info.max.map(|m| m as i64).unwrap_or(i64::MAX); + let new_value = (self.initial_value as i64 + steps).clamp(min, max) as u32; + + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(self.node_id, self.info.parameter_index), + input: NodeInput::value(TaggedValue::U32(new_value), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + + /// Draw the dial as a grabbable handle at the layer origin: an outer ring (the hit target) plus + /// a filled center dot so it reads as draggable. + pub fn overlays(&self, document: &DocumentMessageHandler, _mouse_position: DVec2, overlay_context: &mut OverlayContext) { + if self.state == GenericDialState::Inactive { + return; + } + + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + overlay_context.circle(center, DIAL_INDICATOR_RADIUS, None, None); + overlay_context.manipulator_handle(center, self.state == GenericDialState::Dragging, None); + } + + pub fn mouse_cursor_icon(&self) -> Option { + match self.state { + GenericDialState::Hover | GenericDialState::Dragging => Some(MouseCursorIcon::EWResize), + GenericDialState::Inactive => None, + } + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs new file mode 100644 index 0000000000..8cb54f6624 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -0,0 +1,193 @@ +//! A generic, draggable handle that edits a continuous `f64` node parameter (e.g. a radius). +//! +//! Unlike the hand-written shape gizmos in `shape_gizmos`, this gizmo is fully driven by data +//! from the [gizmo registry](crate::messages::tool::common_functionality::gizmos::gizmo_registry): +//! it knows nothing about the specific node it edits beyond the node id, the parameter index, and +//! the registry's [`GizmoInfo`]. This is what lets any node opt into a slider with zero custom code. + +use crate::consts::GIZMO_HIDE_THRESHOLD; +use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::message::Message; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; +use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoInfo, PositionHint}; +use glam::DVec2; +use graph_craft::ProtoNodeIdentifier; +use graph_craft::document::NodeId; +use graph_craft::document::NodeInput; +use graph_craft::document::value::TaggedValue; +use std::collections::VecDeque; + +/// Pixel radius within which the mouse is considered to be hovering the handle. +const SLIDER_HANDLE_HOVER_THRESHOLD: f64 = 8.; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GenericSliderState { + #[default] + Inactive, + Hover, + Dragging, +} + +/// A draggable slider handle bound to one `f64` parameter of one node. +#[derive(Clone, Debug)] +pub struct GenericSliderGizmo { + layer: LayerNodeIdentifier, + node_id: NodeId, + identifier: ProtoNodeIdentifier, + info: GizmoInfo, + state: GenericSliderState, + /// The parameter value captured when the drag began, used as the clamping/anchor reference. + initial_value: f64, +} + +impl GenericSliderGizmo { + pub fn new(layer: LayerNodeIdentifier, node_id: NodeId, identifier: ProtoNodeIdentifier, info: GizmoInfo) -> Self { + Self { + layer, + node_id, + identifier, + info, + state: GenericSliderState::Inactive, + initial_value: 0., + } + } + + pub fn is_hovered(&self) -> bool { + self.state == GenericSliderState::Hover + } + + pub fn is_dragging(&self) -> bool { + self.state == GenericSliderState::Dragging + } + + pub fn cleanup(&mut self) { + self.state = GenericSliderState::Inactive; + } + + /// Begin a drag if currently hovered. + pub fn handle_click(&mut self) { + if self.state == GenericSliderState::Hover { + self.state = GenericSliderState::Dragging; + } + } + + fn current_value(&self, document: &DocumentMessageHandler) -> Option { + read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index) + } + + /// The handle's anchor point, in the layer's local coordinate space, derived from the current + /// parameter value and the registry's position hint. + fn handle_position_local(&self, value: f64) -> DVec2 { + match self.info.position_hint { + // A length-like parameter: place the handle that far out along the local +X axis. + PositionHint::ParameterDerived => DVec2::new(value.abs(), 0.), + // Generic fall-backs map the value onto the local +X axis as well; bounding-box-aware + // hints are refined as more node types adopt the slider. + _ => DVec2::new(value.abs(), 0.), + } + } + + /// Pure hover test: returns the mouse's distance to the handle when it is a hover candidate, or + /// `None` otherwise. The manager uses this distance to resolve priority when several gizmos + /// overlap (the closest handle wins). This performs no state mutation. + pub fn hover_distance(&self, mouse_position: DVec2, document: &DocumentMessageHandler) -> Option { + let value = self.current_value(document)?; + + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + let handle = viewport.transform_point2(self.handle_position_local(value)); + + // Hide the gizmo when the shape is too small on screen to interact with reliably. + if handle.distance(center) < GIZMO_HIDE_THRESHOLD { + return None; + } + + let distance = mouse_position.distance(handle); + (distance <= SLIDER_HANDLE_HOVER_THRESHOLD).then_some(distance) + } + + /// Transition into the hovered state (no-op if already hovered or dragging). Capturing the + /// reference value here is necessary because `handle_click` (which starts the drag) has no + /// access to the document. + pub fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + if self.state != GenericSliderState::Inactive { + return; + } + let Some(value) = self.current_value(document) else { return }; + + self.state = GenericSliderState::Hover; + self.initial_value = value; + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); + } + + /// Transition out of the hovered state. Leaves an in-progress drag untouched. + pub fn exit_hover(&mut self, responses: &mut VecDeque) { + if self.state == GenericSliderState::Hover { + self.state = GenericSliderState::Inactive; + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); + } + } + + /// Update the parameter live while dragging. The new value is the mouse's position projected + /// onto the local +X axis, clamped to the registry's min/max bounds. + pub fn handle_update(&self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + let viewport = document.metadata().transform_to_viewport(self.layer); + let local_mouse = viewport.inverse().transform_point2(input.mouse.position); + + let mut value = local_mouse.x; + + // Preserve the sign of the original value for parameters (like radius) that can be negative. + if self.initial_value.is_sign_negative() { + value = -value; + } + + value = self.clamp(value); + + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(self.node_id, self.info.parameter_index), + input: NodeInput::value(TaggedValue::F64(value), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + + fn clamp(&self, value: f64) -> f64 { + let mut value = value; + if let Some(min) = self.info.min { + value = value.max(min); + } + if let Some(max) = self.info.max { + value = value.min(max); + } + value + } + + /// Draw the handle dot, plus a guide line from the layer origin while hovered or dragging. + pub fn overlays(&self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { + if self.state == GenericSliderState::Inactive { + return; + } + + let Some(value) = self.current_value(document) else { return }; + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + let handle = viewport.transform_point2(self.handle_position_local(value)); + + if handle.distance(center) < GIZMO_HIDE_THRESHOLD { + return; + } + + overlay_context.line(center, handle, None, None); + overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None); + } + + pub fn mouse_cursor_icon(&self) -> Option { + match self.state { + GenericSliderState::Hover | GenericSliderState::Dragging => Some(MouseCursorIcon::EWResize), + GenericSliderState::Inactive => None, + } + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs new file mode 100644 index 0000000000..a5035ae9de --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -0,0 +1,264 @@ +//! # Generic Gizmos +//! +//! Data-driven, reusable gizmo components that any node can opt into via the +//! [gizmo registry](super::gizmo_registry). Where the legacy `shape_gizmos` each hand-code a +//! shape's interaction, the generic gizmos here are parameterized purely by `(node_id, +//! parameter_index, GizmoInfo)` and therefore work for any node that registers them. +//! +//! - [`GenericSliderGizmo`](generic_slider_gizmo::GenericSliderGizmo) edits an `f64` parameter. +//! - [`GenericDialGizmo`](generic_dial_gizmo::GenericDialGizmo) edits a `u32` parameter. +//! +//! [`GenericGizmoHandler`] ties them together behind the existing +//! [`ShapeGizmoHandler`](crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler) +//! trait, so the [`GizmoManager`](super::gizmo_manager::GizmoManager) can drive them with no +//! knowledge of the underlying node. + +pub mod generic_dial_gizmo; +pub mod generic_slider_gizmo; + +use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::message::Message; +use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoType, registered_gizmo_nodes}; +use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; +use generic_dial_gizmo::GenericDialGizmo; +use generic_slider_gizmo::GenericSliderGizmo; +use glam::DVec2; +use graph_craft::ProtoNodeIdentifier; +use graph_craft::document::value::TaggedValue; +use std::collections::VecDeque; + +/// Read an `f64` node input value by node identifier and parameter index. +pub fn read_f64_input(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, identifier: &ProtoNodeIdentifier, index: usize) -> Option { + let inputs = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(identifier.clone()))?; + match inputs.get(index)?.as_value()? { + TaggedValue::F64(value) => Some(*value), + _ => None, + } +} + +/// Read a `u32` node input value by node identifier and parameter index. +pub fn read_u32_input(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, identifier: &ProtoNodeIdentifier, index: usize) -> Option { + let inputs = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(identifier.clone()))?; + match inputs.get(index)?.as_value()? { + TaggedValue::U32(value) => Some(*value), + _ => None, + } +} + +/// A single generic gizmo instance, dispatching over the supported control types. +#[derive(Clone, Debug)] +enum GenericGizmo { + Slider(GenericSliderGizmo), + Dial(GenericDialGizmo), +} + +impl GenericGizmo { + fn is_hovered(&self) -> bool { + match self { + Self::Slider(g) => g.is_hovered(), + Self::Dial(g) => g.is_hovered(), + } + } + + fn is_dragging(&self) -> bool { + match self { + Self::Slider(g) => g.is_dragging(), + Self::Dial(g) => g.is_dragging(), + } + } + + /// Distance from the mouse to this gizmo's handle when it is a hover candidate, else `None`. + fn hover_distance(&self, mouse_position: DVec2, document: &DocumentMessageHandler) -> Option { + match self { + Self::Slider(g) => g.hover_distance(mouse_position, document), + Self::Dial(g) => g.hover_distance(mouse_position, document), + } + } + + fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + match self { + Self::Slider(g) => g.enter_hover(document, responses), + Self::Dial(g) => g.enter_hover(document, responses), + } + } + + fn exit_hover(&mut self, responses: &mut VecDeque) { + match self { + Self::Slider(g) => g.exit_hover(responses), + Self::Dial(g) => g.exit_hover(responses), + } + } + + fn handle_click(&mut self) { + match self { + Self::Slider(g) => g.handle_click(), + Self::Dial(g) => g.handle_click(), + } + } + + fn handle_update(&self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + match self { + Self::Slider(g) => g.handle_update(document, input, responses), + Self::Dial(g) => g.handle_update(drag_start, document, input, responses), + } + } + + fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, overlay_context: &mut OverlayContext) { + match self { + Self::Slider(g) => g.overlays(document, overlay_context), + Self::Dial(g) => g.overlays(document, mouse_position, overlay_context), + } + } + + fn cleanup(&mut self) { + match self { + Self::Slider(g) => g.cleanup(), + Self::Dial(g) => g.cleanup(), + } + } + + fn mouse_cursor_icon(&self) -> Option { + match self { + Self::Slider(g) => g.mouse_cursor_icon(), + Self::Dial(g) => g.mouse_cursor_icon(), + } + } +} + +/// A registry-driven gizmo manager. On construction it looks up the selected layer's generator +/// node in the [gizmo registry](super::gizmo_registry) and instantiates the appropriate generic +/// gizmos, so it can stand in for a hand-written `ShapeGizmoHandler` with no node-specific code. +/// +/// It owns a `Vec` and routes all interaction events to them, resolving priority +/// when multiple handles overlap (the handle closest to the cursor wins the hover). +#[derive(Clone, Debug, Default)] +pub struct GenericGizmoManager { + gizmos: Vec, +} + +impl GenericGizmoManager { + /// Query the registry for `layer`'s node and instantiate its gizmos. Returns `None` when the + /// layer has no registry entry (so callers can fall through to legacy shape-specific handlers) + /// or when none of its registered parameters use a currently-supported gizmo type. + pub fn detect_gizmos(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Option { + let node_graph_layer = NodeGraphLayer::new(layer, &document.network_interface); + + for (identifier, infos) in registered_gizmo_nodes() { + let Some(node_id) = node_graph_layer.upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(identifier.clone())) else { + continue; + }; + + let mut gizmos = Vec::new(); + for info in infos { + match info.gizmo_type { + GizmoType::Slider => gizmos.push(GenericGizmo::Slider(GenericSliderGizmo::new(layer, node_id, identifier.clone(), *info))), + GizmoType::Dial => gizmos.push(GenericGizmo::Dial(GenericDialGizmo::new(layer, node_id, identifier.clone(), *info))), + // Position and Angle gizmos are not yet implemented; they are skipped so a + // partially-migrated node still gets its slider/dial controls. + GizmoType::Position | GizmoType::Angle => {} + } + } + + if !gizmos.is_empty() { + return Some(Self { gizmos }); + } + } + + None + } + + /// Index of the gizmo whose handle is closest to the cursor among all hover candidates. + /// This is the priority rule for overlapping handles: nearest wins, ties broken by the + /// registry declaration order (earlier entries win). + fn closest_hover_candidate(&self, mouse_position: DVec2, document: &DocumentMessageHandler) -> Option { + self.gizmos + .iter() + .enumerate() + .filter_map(|(index, gizmo)| gizmo.hover_distance(mouse_position, document).map(|distance| (index, distance))) + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(index, _)| index) + } +} + +impl ShapeGizmoHandler for GenericGizmoManager { + fn is_any_gizmo_hovered(&self) -> bool { + self.gizmos.iter().any(GenericGizmo::is_hovered) + } + + fn handle_state(&mut self, _selected_shape_layers: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { + // Don't recompute hover while a drag is in progress: the dragging gizmo keeps ownership. + if self.gizmos.iter().any(GenericGizmo::is_dragging) { + return; + } + + // Resolve priority centrally so two overlapping handles never highlight at once: only the + // closest candidate enters the hover state; every other gizmo leaves it. + let winner = self.closest_hover_candidate(mouse_position, document); + for (index, gizmo) in self.gizmos.iter_mut().enumerate() { + if Some(index) == winner { + gizmo.enter_hover(document, responses); + } else { + gizmo.exit_hover(responses); + } + } + } + + fn handle_click(&mut self) { + if let Some(gizmo) = self.gizmos.iter_mut().find(|gizmo| gizmo.is_hovered()) { + gizmo.handle_click(); + } + } + + fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + for gizmo in &self.gizmos { + if gizmo.is_dragging() { + gizmo.handle_update(drag_start, document, input, responses); + } + } + } + + fn overlays( + &self, + document: &DocumentMessageHandler, + _selected_shape_layers: Option, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + mouse_position: DVec2, + overlay_context: &mut OverlayContext, + ) { + for gizmo in &self.gizmos { + gizmo.overlays(document, mouse_position, overlay_context); + } + } + + fn dragging_overlays( + &self, + document: &DocumentMessageHandler, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + mouse_position: DVec2, + overlay_context: &mut OverlayContext, + ) { + for gizmo in &self.gizmos { + if gizmo.is_dragging() { + gizmo.overlays(document, mouse_position, overlay_context); + } + } + } + + fn cleanup(&mut self) { + for gizmo in &mut self.gizmos { + gizmo.cleanup(); + } + } + + fn mouse_cursor_icon(&self) -> Option { + self.gizmos.iter().find_map(GenericGizmo::mouse_cursor_icon) + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 3277dbfebb..1e77acd86e 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -3,6 +3,7 @@ use crate::messages::message::Message; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler}; +use crate::messages::tool::common_functionality::gizmos::generic_gizmos::GenericGizmoManager; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; @@ -34,6 +35,9 @@ pub enum ShapeGizmoHandlers { Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), Heart(HeartGizmoHandler), + /// Registry-driven generic handler. Used for nodes that declare their gizmos in the + /// [gizmo registry](super::gizmo_registry) rather than via a hand-written handler. + Generic(GenericGizmoManager), } impl ShapeGizmoHandlers { @@ -48,6 +52,7 @@ impl ShapeGizmoHandlers { Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", Self::Heart(_) => "heart", + Self::Generic(_) => "generic", Self::None => "none", } } @@ -62,6 +67,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), + Self::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -76,6 +82,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), Self::Heart(h) => h.is_any_gizmo_hovered(), + Self::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -90,6 +97,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), Self::Heart(h) => h.handle_click(), + Self::Generic(h) => h.handle_click(), Self::None => {} } } @@ -104,6 +112,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), Self::Heart(h) => h.handle_update(drag_start, document, input, responses), + Self::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -118,6 +127,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), Self::Heart(h) => h.cleanup(), + Self::Generic(h) => h.cleanup(), Self::None => {} } } @@ -140,6 +150,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), + Self::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -161,6 +172,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), + Self::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -174,6 +186,7 @@ impl ShapeGizmoHandlers { Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), Self::Heart(h) => h.mouse_cursor_icon(), + Self::Generic(h) => h.mouse_cursor_icon(), Self::None => None, } } diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs new file mode 100644 index 0000000000..d919ba2da5 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -0,0 +1,294 @@ +//! # Gizmo Registry +//! +//! A data-driven lookup that maps node types to the parameters that should be exposed as +//! interactive canvas gizmos. This is the foundation of the *generic* gizmo system: instead of +//! writing a bespoke handler for every shape (see the `shape_gizmos` module for the legacy, +//! hand-written handlers), a node simply declares which of its inputs are gizmo-enabled here and +//! the generic gizmo manager builds the appropriate interactive handles automatically. +//! +//! To add gizmos to a new node: +//! 1. Add a `const` slice of [`GizmoInfo`] describing its gizmo-enabled parameters. +//! 2. Register the node's [`ProtoNodeIdentifier`] in [`registered_gizmo_nodes`]. +//! +//! See `GENERIC_GIZMOS.md` (next to this file) for a full walkthrough. + +use graph_craft::ProtoNodeIdentifier; +use graphene_std::NodeInputDecleration; +use graphene_std::vector::generator_nodes; +use graphene_std::vector::generator_nodes::{grid, spiral}; + +/// The kind of interactive control a gizmo presents, which also determines the underlying +/// [`TaggedValue`](graph_craft::document::value::TaggedValue) type of the parameter it edits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GizmoType { + /// A draggable handle that edits a continuous `f64` parameter (e.g. a radius or length). + Slider, + /// A rotary dial that edits a discrete `u32` parameter (e.g. a number of sides). + Dial, + /// A draggable point that edits a `DVec2` parameter (e.g. a position or 2D spacing). + Position, + /// A draggable handle constrained to a circle that edits an angle, stored as `f64` degrees. + Angle, +} + +/// A hint describing where a gizmo's handle should be anchored relative to its layer. Handle +/// positioning varies per node type, so this lets the registry declare intent while leaving the +/// concrete math to the generic gizmo implementations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PositionHint { + /// Anchor at the center of the layer's bounding box. + BoundingBoxCenter, + /// Anchor on the right/middle edge of the layer's bounding box. + BoundingBoxEdge, + /// Anchor at the top-right corner of the layer's bounding box. + BoundingBoxCorner, + /// Derive the anchor from the parameter's own value (e.g. a radius handle sits at distance + /// `value` from the layer origin). The most precise option for length-like parameters. + ParameterDerived, +} + +/// Describes a single gizmo-enabled parameter of a node: which input it edits, how it should be +/// presented, and the constraints/positioning that apply. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GizmoInfo { + /// The index of the node input this gizmo edits. + pub parameter_index: usize, + /// The control type to instantiate for this parameter. + pub gizmo_type: GizmoType, + /// A human-readable name, shown in overlays/tooltips. + pub name: &'static str, + /// Inclusive lower bound for the value, if any. + pub min: Option, + /// Inclusive upper bound for the value, if any. + pub max: Option, + /// Where the gizmo's handle should be anchored. + pub position_hint: PositionHint, +} + +// --- Per-node gizmo declarations ------------------------------------------------------------ + +const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, +}]; + +// Only the sides dial: a polygon's radius is already adjustable via the transform cage, and a +// `(radius, 0)` slider handle lands off the polygon's geometry, so it adds confusion without value. +const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Dial, + name: "Sides", + min: Some(3.), + max: None, + position_hint: PositionHint::BoundingBoxCenter, +}]; + +const STAR_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Dial, + name: "Points", + min: Some(3.), + max: None, + position_hint: PositionHint::BoundingBoxCenter, + }, + GizmoInfo { + parameter_index: 2, + gizmo_type: GizmoType::Slider, + name: "Outer Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: 3, + gizmo_type: GizmoType::Slider, + name: "Inner Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, +]; + +const ARC_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: 2, + gizmo_type: GizmoType::Angle, + name: "Start Angle", + min: None, + max: None, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: 3, + gizmo_type: GizmoType::Angle, + name: "Sweep Angle", + min: None, + max: None, + position_hint: PositionHint::ParameterDerived, + }, +]; + +const SPIRAL_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: spiral::InnerRadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Inner Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: spiral::OuterRadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Outer Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: spiral::TurnsInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Turns", + min: Some(0.), + max: None, + position_hint: PositionHint::BoundingBoxEdge, + }, +]; + +// Only the radius slider: the heart's many shaping parameters (cleavage, lobes, shoulders, point) +// are fine-tuned via the Properties panel, while the overall size reads naturally as a canvas handle. +const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, +}]; + +const GRID_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: grid::ColumnsInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Columns", + min: Some(1.), + max: None, + position_hint: PositionHint::BoundingBoxCorner, + }, + GizmoInfo { + parameter_index: grid::RowsInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Rows", + min: Some(1.), + max: None, + position_hint: PositionHint::BoundingBoxCorner, + }, + GizmoInfo { + parameter_index: grid::SpacingInput::::INDEX, + gizmo_type: GizmoType::Position, + name: "Spacing", + min: Some(0.), + max: None, + position_hint: PositionHint::BoundingBoxCorner, + }, +]; + +/// Returns every node type that has registered gizmos, paired with its gizmo declarations. +/// +/// The identifier is cloned at call time because [`ProtoNodeIdentifier`]s are not trivially +/// usable as `'static` references in a `const`. This is cheap (the identifiers are backed by +/// `&'static str`) and only runs when a selection changes. +pub fn registered_gizmo_nodes() -> Vec<(ProtoNodeIdentifier, &'static [GizmoInfo])> { + vec![ + (generator_nodes::circle::IDENTIFIER, CIRCLE_GIZMOS), + (generator_nodes::regular_polygon::IDENTIFIER, POLYGON_GIZMOS), + (generator_nodes::star::IDENTIFIER, STAR_GIZMOS), + (generator_nodes::arc::IDENTIFIER, ARC_GIZMOS), + (generator_nodes::spiral::IDENTIFIER, SPIRAL_GIZMOS), + (generator_nodes::grid::IDENTIFIER, GRID_GIZMOS), + (generator_nodes::heart::IDENTIFIER, HEART_GIZMOS), + ] +} + +/// Looks up the gizmo declarations for a given node type. Returns an empty slice when the node +/// has no registered gizmos. +pub fn get_gizmo_info(identifier: &ProtoNodeIdentifier) -> &'static [GizmoInfo] { + registered_gizmo_nodes() + .into_iter() + .find(|(registered, _)| registered.as_str() == identifier.as_str()) + .map(|(_, infos)| infos) + .unwrap_or(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn circle_exposes_a_radius_slider() { + let infos = get_gizmo_info(&generator_nodes::circle::IDENTIFIER); + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].parameter_index, 1); + assert_eq!(infos[0].gizmo_type, GizmoType::Slider); + assert_eq!(infos[0].min, Some(0.)); + assert_eq!(infos[0].position_hint, PositionHint::ParameterDerived); + } + + #[test] + fn polygon_exposes_only_a_sides_dial() { + let infos = get_gizmo_info(&generator_nodes::regular_polygon::IDENTIFIER); + assert_eq!(infos.len(), 1); + + let sides = &infos[0]; + assert_eq!(sides.gizmo_type, GizmoType::Dial); + assert_eq!(sides.parameter_index, 1); + assert_eq!(sides.min, Some(3.)); + + // The radius is intentionally not exposed as a gizmo (handled by the transform cage instead). + assert!(infos.iter().all(|info| info.gizmo_type != GizmoType::Slider)); + } + + #[test] + fn star_exposes_a_points_dial_and_two_radius_sliders() { + let infos = get_gizmo_info(&generator_nodes::star::IDENTIFIER); + assert_eq!(infos.iter().filter(|info| info.gizmo_type == GizmoType::Dial).count(), 1); + assert_eq!(infos.iter().filter(|info| info.gizmo_type == GizmoType::Slider).count(), 2); + } + + #[test] + fn heart_exposes_only_a_radius_slider() { + let infos = get_gizmo_info(&generator_nodes::heart::IDENTIFIER); + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].parameter_index, 1); + assert_eq!(infos[0].gizmo_type, GizmoType::Slider); + assert_eq!(infos[0].min, Some(0.)); + assert_eq!(infos[0].position_hint, PositionHint::ParameterDerived); + } + + #[test] + fn all_existing_shapes_are_registered() { + assert_eq!(registered_gizmo_nodes().len(), 7); + for (_, infos) in registered_gizmo_nodes() { + assert!(!infos.is_empty(), "every registered node must declare at least one gizmo"); + } + } + + #[test] + fn unregistered_node_returns_no_gizmos() { + // The Fill node is not a generator with gizmos, so it must return an empty slice. + assert!(get_gizmo_info(&graphene_std::vector_nodes::fill::IDENTIFIER).is_empty()); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/mod.rs index 108c45d6a3..bc8437929e 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/mod.rs @@ -1,2 +1,4 @@ +pub mod generic_gizmos; pub mod gizmo_manager; +pub mod gizmo_registry; pub mod shape_gizmos; From 2f1a5bf2c5cae76d815526140e81322144ad08dc Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 6 Jul 2026 23:57:56 +0530 Subject: [PATCH 05/24] Migrate the Circle gizmo to the generic registry system --- .../gizmos/gizmo_manager.rs | 15 +--- .../shapes/circle_shape.rs | 69 +------------------ 2 files changed, 3 insertions(+), 81 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 1e77acd86e..70c1f7926e 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -7,7 +7,6 @@ use crate::messages::tool::common_functionality::gizmos::generic_gizmos::Generic use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; -use crate::messages::tool::common_functionality::shapes::circle_shape::CircleGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; use crate::messages::tool::common_functionality::shapes::polygon_shape::PolygonGizmoHandler; @@ -31,7 +30,6 @@ pub enum ShapeGizmoHandlers { Star(StarGizmoHandler), Polygon(PolygonGizmoHandler), Arc(ArcGizmoHandler), - Circle(CircleGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), Heart(HeartGizmoHandler), @@ -48,7 +46,6 @@ impl ShapeGizmoHandlers { Self::Star(_) => "star", Self::Polygon(_) => "polygon", Self::Arc(_) => "arc", - Self::Circle(_) => "circle", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", Self::Heart(_) => "heart", @@ -63,7 +60,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.handle_state(layer, mouse_position, document, responses), Self::Polygon(h) => h.handle_state(layer, mouse_position, document, responses), Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), - Self::Circle(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), @@ -78,7 +74,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.is_any_gizmo_hovered(), Self::Polygon(h) => h.is_any_gizmo_hovered(), Self::Arc(h) => h.is_any_gizmo_hovered(), - Self::Circle(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), Self::Heart(h) => h.is_any_gizmo_hovered(), @@ -93,7 +88,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.handle_click(), Self::Polygon(h) => h.handle_click(), Self::Arc(h) => h.handle_click(), - Self::Circle(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), Self::Heart(h) => h.handle_click(), @@ -108,7 +102,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.handle_update(drag_start, document, input, responses), Self::Polygon(h) => h.handle_update(drag_start, document, input, responses), Self::Arc(h) => h.handle_update(drag_start, document, input, responses), - Self::Circle(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), Self::Heart(h) => h.handle_update(drag_start, document, input, responses), @@ -123,7 +116,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.cleanup(), Self::Polygon(h) => h.cleanup(), Self::Arc(h) => h.cleanup(), - Self::Circle(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), Self::Heart(h) => h.cleanup(), @@ -146,7 +138,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Polygon(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), - Self::Circle(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), @@ -168,7 +159,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Polygon(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), - Self::Circle(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), @@ -182,7 +172,6 @@ impl ShapeGizmoHandlers { Self::Star(h) => h.mouse_cursor_icon(), Self::Polygon(h) => h.mouse_cursor_icon(), Self::Arc(h) => h.mouse_cursor_icon(), - Self::Circle(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), Self::Heart(h) => h.mouse_cursor_icon(), @@ -226,9 +215,9 @@ impl GizmoManager { if graph_modification_utils::get_arc_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Arc(ArcGizmoHandler::new())); } - // Circle + // Circle — migrated to the generic, registry-driven gizmo system (radius slider). if graph_modification_utils::get_circle_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Circle(CircleGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Grid if graph_modification_utils::get_grid_id(layer, &document.network_interface).is_some() { diff --git a/editor/src/messages/tool/common_functionality/shapes/circle_shape.rs b/editor/src/messages/tool/common_functionality/shapes/circle_shape.rs index c30e54af12..e616aff465 100644 --- a/editor/src/messages/tool/common_functionality/shapes/circle_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/circle_shape.rs @@ -1,81 +1,14 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::circle_arc_radius_handle::{RadiusHandle, RadiusHandleState}; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::resize::{viewport_zoom, window_aligned_transform_set}; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, ShapeToolModifierKey}; +use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeToolModifierKey; use crate::messages::tool::tool_messages::shape_tool::ShapeToolData; use crate::messages::tool::tool_messages::tool_prelude::*; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; -#[derive(Clone, Debug, Default)] -pub struct CircleGizmoHandler { - circle_radius_handle: RadiusHandle, -} - -impl ShapeGizmoHandler for CircleGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - self.circle_radius_handle.hovered() - } - - fn handle_state(&mut self, selected_circle_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - self.circle_radius_handle.handle_actions(selected_circle_layer, document, mouse_position, responses); - } - - fn handle_click(&mut self) { - if self.circle_radius_handle.hovered() { - self.circle_radius_handle.update_state(RadiusHandleState::Dragging); - } - } - - fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.circle_radius_handle.is_dragging() { - self.circle_radius_handle.update_inner_radius(document, input, responses, drag_start); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - _selected_circle_layer: Option, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - self.circle_radius_handle.overlays(document, overlay_context); - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - if self.circle_radius_handle.is_dragging() { - self.circle_radius_handle.overlays(document, overlay_context); - } - } - - fn cleanup(&mut self) { - self.circle_radius_handle.cleanup(); - } - - fn mouse_cursor_icon(&self) -> Option { - if self.circle_radius_handle.hovered() || self.circle_radius_handle.is_dragging() { - return Some(MouseCursorIcon::EWResize); - } - - None - } -} - #[derive(Default)] pub struct Circle; From 82c24b56c868cae50d0b35ab01a30054bf687b62 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 6 Jul 2026 23:58:36 +0530 Subject: [PATCH 06/24] Migrate the Polygon gizmo to the generic registry system --- .../gizmos/gizmo_manager.rs | 15 +-- .../shapes/polygon_shape.rs | 92 ------------------- 2 files changed, 2 insertions(+), 105 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 70c1f7926e..c4bb517a89 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -9,7 +9,6 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; -use crate::messages::tool::common_functionality::shapes::polygon_shape::PolygonGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; use crate::messages::tool::common_functionality::shapes::star_shape::StarGizmoHandler; @@ -28,7 +27,6 @@ pub enum ShapeGizmoHandlers { #[default] None, Star(StarGizmoHandler), - Polygon(PolygonGizmoHandler), Arc(ArcGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), @@ -44,7 +42,6 @@ impl ShapeGizmoHandlers { pub fn kind(&self) -> &'static str { match self { Self::Star(_) => "star", - Self::Polygon(_) => "polygon", Self::Arc(_) => "arc", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", @@ -58,7 +55,6 @@ impl ShapeGizmoHandlers { pub fn handle_state(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { match self { Self::Star(h) => h.handle_state(layer, mouse_position, document, responses), - Self::Polygon(h) => h.handle_state(layer, mouse_position, document, responses), Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), @@ -72,7 +68,6 @@ impl ShapeGizmoHandlers { pub fn is_any_gizmo_hovered(&self) -> bool { match self { Self::Star(h) => h.is_any_gizmo_hovered(), - Self::Polygon(h) => h.is_any_gizmo_hovered(), Self::Arc(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), @@ -86,7 +81,6 @@ impl ShapeGizmoHandlers { pub fn handle_click(&mut self) { match self { Self::Star(h) => h.handle_click(), - Self::Polygon(h) => h.handle_click(), Self::Arc(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), @@ -100,7 +94,6 @@ impl ShapeGizmoHandlers { pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { match self { Self::Star(h) => h.handle_update(drag_start, document, input, responses), - Self::Polygon(h) => h.handle_update(drag_start, document, input, responses), Self::Arc(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), @@ -114,7 +107,6 @@ impl ShapeGizmoHandlers { pub fn cleanup(&mut self) { match self { Self::Star(h) => h.cleanup(), - Self::Polygon(h) => h.cleanup(), Self::Arc(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), @@ -136,7 +128,6 @@ impl ShapeGizmoHandlers { ) { match self { Self::Star(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), - Self::Polygon(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), @@ -157,7 +148,6 @@ impl ShapeGizmoHandlers { ) { match self { Self::Star(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), - Self::Polygon(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), @@ -170,7 +160,6 @@ impl ShapeGizmoHandlers { pub fn gizmo_cursor_icon(&self) -> Option { match self { Self::Star(h) => h.mouse_cursor_icon(), - Self::Polygon(h) => h.mouse_cursor_icon(), Self::Arc(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), @@ -207,9 +196,9 @@ impl GizmoManager { if graph_modification_utils::get_star_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Star(StarGizmoHandler::default())); } - // Polygon + // Polygon — migrated to the generic, registry-driven gizmo system (sides dial). if graph_modification_utils::get_polygon_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Polygon(PolygonGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Arc if graph_modification_utils::get_arc_id(layer, &document.network_interface).is_some() { diff --git a/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs b/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs index 221109defa..6ef88141d1 100644 --- a/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs @@ -1,107 +1,15 @@ use super::shape_utility::{ShapeToolModifierKey, update_radius_sign}; use super::*; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type}; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::number_of_points_dial::{NumberOfPointsDial, NumberOfPointsDialState}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::point_radius_handle::{PointRadiusHandle, PointRadiusHandleState}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer}; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, polygon_outline}; use crate::messages::tool::tool_messages::shape_tool::ShapeOptionsUpdate; use crate::messages::tool::tool_messages::tool_prelude::*; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use std::collections::VecDeque; -#[derive(Clone, Debug, Default)] -pub struct PolygonGizmoHandler { - number_of_points_dial: NumberOfPointsDial, - point_radius_handle: PointRadiusHandle, -} - -impl ShapeGizmoHandler for PolygonGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - self.number_of_points_dial.is_hovering() || self.point_radius_handle.hovered() - } - - fn handle_state(&mut self, selected_star_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - self.number_of_points_dial.handle_actions(selected_star_layer, mouse_position, document, responses); - self.point_radius_handle.handle_actions(selected_star_layer, document, mouse_position, responses); - } - - fn handle_click(&mut self) { - if self.number_of_points_dial.is_hovering() { - self.number_of_points_dial.update_state(NumberOfPointsDialState::Dragging); - return; - } - - if self.point_radius_handle.hovered() { - self.point_radius_handle.update_state(PointRadiusHandleState::Dragging); - } - } - - fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.number_of_points_dial.is_dragging() { - self.number_of_points_dial.update_number_of_sides(document, input, responses, drag_start); - } - - if self.point_radius_handle.is_dragging_or_snapped() { - self.point_radius_handle.update_inner_radius(document, input, responses, drag_start); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - selected_polygon_layer: Option, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - self.number_of_points_dial.overlays(document, selected_polygon_layer, shape_editor, mouse_position, overlay_context); - self.point_radius_handle.overlays(selected_polygon_layer, document, overlay_context); - - polygon_outline(selected_polygon_layer, document, overlay_context); - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - if self.number_of_points_dial.is_dragging() { - self.number_of_points_dial.overlays(document, None, shape_editor, mouse_position, overlay_context); - } - - if self.point_radius_handle.is_dragging_or_snapped() { - self.point_radius_handle.overlays(None, document, overlay_context); - } - } - - fn mouse_cursor_icon(&self) -> Option { - if self.number_of_points_dial.is_dragging() || self.number_of_points_dial.is_hovering() { - return Some(MouseCursorIcon::EWResize); - } - - if self.point_radius_handle.is_dragging_or_snapped() || self.point_radius_handle.hovered() { - return Some(MouseCursorIcon::Default); - } - - None - } - - fn cleanup(&mut self) { - self.number_of_points_dial.cleanup(); - self.point_radius_handle.cleanup(); - } -} - #[derive(Default)] pub struct Polygon; From 0d04001e19e2845f8901892b02d499a8ed13c16d Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 6 Jul 2026 23:59:11 +0530 Subject: [PATCH 07/24] Add the Heart's radius gizmo via the generic registry system --- .../gizmos/gizmo_manager.rs | 22 +++----- .../shapes/heart_shape.rs | 53 ++----------------- .../messages/tool/tool_messages/shape_tool.rs | 8 +++ 3 files changed, 19 insertions(+), 64 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index c4bb517a89..3cb76d06d1 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -8,7 +8,6 @@ use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; -use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; use crate::messages::tool::common_functionality::shapes::star_shape::StarGizmoHandler; @@ -17,11 +16,12 @@ use std::collections::VecDeque; /// A unified enum wrapper around all available shape-specific gizmo handlers. /// -/// This abstraction allows `GizmoManager` to interact with different shape gizmos (like Star or Polygon) +/// This abstraction allows `GizmoManager` to interact with different shape gizmos (like Star or Arc) /// using a common interface without needing to know the specific shape type at compile time. /// -/// Each variant stores a concrete handler (e.g., `StarGizmoHandler`, `PolygonGizmoHandler`) that implements -/// the shape-specific logic for rendering overlays, responding to input, and modifying shape parameters. +/// Each variant stores a concrete handler (e.g., `StarGizmoHandler`) that implements the shape-specific +/// logic for rendering overlays, responding to input, and modifying shape parameters. Shapes whose +/// gizmos have been migrated to the registry-driven system (polygon, circle, heart) use the `Generic` variant. #[derive(Clone, Debug, Default)] pub enum ShapeGizmoHandlers { #[default] @@ -30,7 +30,6 @@ pub enum ShapeGizmoHandlers { Arc(ArcGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), - Heart(HeartGizmoHandler), /// Registry-driven generic handler. Used for nodes that declare their gizmos in the /// [gizmo registry](super::gizmo_registry) rather than via a hand-written handler. Generic(GenericGizmoManager), @@ -45,7 +44,6 @@ impl ShapeGizmoHandlers { Self::Arc(_) => "arc", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", - Self::Heart(_) => "heart", Self::Generic(_) => "generic", Self::None => "none", } @@ -58,7 +56,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), - Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), Self::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } @@ -71,7 +68,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), - Self::Heart(h) => h.is_any_gizmo_hovered(), Self::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } @@ -84,7 +80,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), - Self::Heart(h) => h.handle_click(), Self::Generic(h) => h.handle_click(), Self::None => {} } @@ -97,7 +92,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), - Self::Heart(h) => h.handle_update(drag_start, document, input, responses), Self::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } @@ -110,7 +104,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), - Self::Heart(h) => h.cleanup(), Self::Generic(h) => h.cleanup(), Self::None => {} } @@ -131,7 +124,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), - Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -151,7 +143,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), - Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -163,7 +154,6 @@ impl ShapeGizmoHandlers { Self::Arc(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), - Self::Heart(h) => h.mouse_cursor_icon(), Self::Generic(h) => h.mouse_cursor_icon(), Self::None => None, } @@ -216,9 +206,9 @@ impl GizmoManager { if graph_modification_utils::get_spiral_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Spiral(SpiralGizmoHandler::default())); } - // Heart + // Heart — migrated to the generic, registry-driven gizmo system (radius slider). if graph_modification_utils::get_heart_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Heart(HeartGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } None diff --git a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs index 2fc5d4fffd..cca9090626 100644 --- a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -1,14 +1,11 @@ -use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::message::Message; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler}; use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, ShapeToolModifierKey}; +use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeToolModifierKey; use crate::messages::tool::tool_messages::shape_tool::ShapeToolData; use crate::messages::tool::tool_messages::tool_prelude::*; use glam::DAffine2; @@ -16,50 +13,10 @@ use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use std::collections::VecDeque; -/// Placeholder gizmo handler for the Heart shape. -/// The heart's parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. -#[derive(Clone, Debug, Default)] -pub struct HeartGizmoHandler; - -impl ShapeGizmoHandler for HeartGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - false - } - - fn handle_state(&mut self, _layer: LayerNodeIdentifier, _mouse_position: DVec2, _document: &DocumentMessageHandler, _responses: &mut VecDeque) {} - - fn handle_click(&mut self) {} - - fn handle_update(&mut self, _drag_start: DVec2, _document: &DocumentMessageHandler, _input: &InputPreprocessorMessageHandler, _responses: &mut VecDeque) {} - - fn overlays( - &self, - _document: &DocumentMessageHandler, - _selected_layer: Option, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - _overlay_context: &mut OverlayContext, - ) { - } - - fn dragging_overlays( - &self, - _document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - _overlay_context: &mut OverlayContext, - ) { - } - - fn cleanup(&mut self) {} - - fn mouse_cursor_icon(&self) -> Option { - None - } -} - +/// The heart's size is adjusted via a registry-driven radius gizmo (see the [gizmo registry]), while its +/// parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. +/// +/// [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry #[derive(Default)] pub struct Heart; diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index b4e268742a..3711efbdff 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -577,11 +577,19 @@ impl<'a> MessageHandler> for Shap let is_set_shape = matches!(&message, ToolMessage::Shape(ShapeToolMessage::SetShape { .. })); let shape_before = self.tool_data.current_shape; + // A gizmo drag writes a node input directly (the generic gizmos are node-agnostic and know nothing about the + // control bar). Any parameter the control bar mirrors — polygon/star sides, spiral turns, etc. — must be re-read + // from the selected layer afterward, otherwise the control bar fields go stale. We reuse the same live-read sync + // that runs on `SelectionChanged`, so every edit path (gizmo, properties panel, API) stays consistent. + let is_gizmo_drag = matches!(&message, ToolMessage::Shape(ShapeToolMessage::PointerMove { .. })) && matches!(self.fsm_state, ShapeToolFsmState::ModifyingGizmo); + let ToolMessage::Shape(ShapeToolMessage::UpdateOptions { options }) = message else { self.fsm_state.process_event(message, &mut self.tool_data, context, &self.options, responses, true); if is_set_shape { handle_shape_mode_change(&mut self.options, self.tool_data.current_shape, shape_before, context.global_tool_data, context.document); self.send_layout(responses, LayoutTarget::ToolOptions); + } else if is_gizmo_drag && sync_shape_options_from_selection(&mut self.options, &mut self.tool_data, context.document) { + self.send_layout(responses, LayoutTarget::ToolOptions); } return; }; From 46c540ae6e77ea702af861993b5b46ec22083bab Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 22 Aug 2026 00:16:01 +0530 Subject: [PATCH 08/24] Port the gizmo registry stack to current master's geometry and parameter APIs The stack was written against the 2026-07-23 fork point and does not build on current master. Two of master's changes land on it directly: - #4457 deleted the legacy `subpath` module. The Heart node was the only remaining caller of `ManipulatorGroup`/`Subpath::new`/`Vector::from_subpath`. Its anchor math moves to a `heart_bezpath` constructor in `shapes.rs`, alongside the other generators, so the node body stays as thin as its neighbors and the load-bearing winding order lives with the rest of the geometry. - #4387 replaced raw input indices with parameter symbols, removing the `NodeInputDecleration` trait and `grid::SpacingInput`'s type parameter. The registry's remaining `parameter_index: 1` literals become the generated symbols, and the generic gizmos pair their runtime-chosen index back with their node identifier through `ParameterRef` rather than passing a bare `usize` to `InputConnector::node`. No behavior change. `cargo test -p graphite-editor -p vector-nodes` passes (225 tests), including the six registry tests. --- .../generic_gizmos/generic_dial_gizmo.rs | 13 +++- .../generic_gizmos/generic_slider_gizmo.rs | 13 +++- .../gizmos/gizmo_registry.rs | 24 +++--- .../shapes/heart_shape.rs | 2 +- .../src/vector/algorithms/shapes.rs | 73 +++++++++++++++++++ .../nodes/vector/src/generator_nodes.rs | 59 +++++---------- 6 files changed, 128 insertions(+), 56 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs index e90049562e..6326618e37 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -20,6 +20,7 @@ use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; +use graphene_std::ParameterRef; use std::collections::VecDeque; /// Horizontal drag distance (viewport px) that corresponds to one integer step. @@ -80,6 +81,16 @@ impl GenericDialGizmo { } } + /// The registry entry's parameter, re-paired with the node it was declared for. `ParameterRef` is the + /// runtime form of a parameter symbol: the generic gizmos choose their parameter from the registry at + /// runtime, so they cannot name a symbol at the call site, but the identifier and index still travel together. + fn parameter(&self) -> ParameterRef { + ParameterRef { + node_identifier: self.identifier.clone(), + input_index: self.info.parameter_index, + } + } + fn current_value(&self, document: &DocumentMessageHandler) -> Option { read_u32_input(self.layer, document, &self.identifier, self.info.parameter_index) } @@ -138,7 +149,7 @@ impl GenericDialGizmo { let new_value = (self.initial_value as i64 + steps).clamp(min, max) as u32; responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(self.node_id, self.info.parameter_index), + input_connector: InputConnector::node(self.node_id, self.parameter()), input: NodeInput::value(TaggedValue::U32(new_value), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 8cb54f6624..5727aded78 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -19,6 +19,7 @@ use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; +use graphene_std::ParameterRef; use std::collections::VecDeque; /// Pixel radius within which the mouse is considered to be hovering the handle. @@ -75,6 +76,16 @@ impl GenericSliderGizmo { } } + /// The registry entry's parameter, re-paired with the node it was declared for. `ParameterRef` is the + /// runtime form of a parameter symbol: the generic gizmos choose their parameter from the registry at + /// runtime, so they cannot name a symbol at the call site, but the identifier and index still travel together. + fn parameter(&self) -> ParameterRef { + ParameterRef { + node_identifier: self.identifier.clone(), + input_index: self.info.parameter_index, + } + } + fn current_value(&self, document: &DocumentMessageHandler) -> Option { read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index) } @@ -148,7 +159,7 @@ impl GenericSliderGizmo { value = self.clamp(value); responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(self.node_id, self.info.parameter_index), + input_connector: InputConnector::node(self.node_id, self.parameter()), input: NodeInput::value(TaggedValue::F64(value), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index d919ba2da5..fb3c9816bf 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -13,9 +13,9 @@ //! See `GENERIC_GIZMOS.md` (next to this file) for a full walkthrough. use graph_craft::ProtoNodeIdentifier; -use graphene_std::NodeInputDecleration; +use graphene_std::NodeParameter; use graphene_std::vector::generator_nodes; -use graphene_std::vector::generator_nodes::{grid, spiral}; +use graphene_std::vector::generator_nodes::{arc, circle, grid, heart, regular_polygon, spiral, star}; /// The kind of interactive control a gizmo presents, which also determines the underlying /// [`TaggedValue`](graph_craft::document::value::TaggedValue) type of the parameter it edits. @@ -68,7 +68,7 @@ pub struct GizmoInfo { // --- Per-node gizmo declarations ------------------------------------------------------------ const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { - parameter_index: 1, + parameter_index: circle::RadiusInput::INDEX, gizmo_type: GizmoType::Slider, name: "Radius", min: Some(0.), @@ -79,7 +79,7 @@ const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { // Only the sides dial: a polygon's radius is already adjustable via the transform cage, and a // `(radius, 0)` slider handle lands off the polygon's geometry, so it adds confusion without value. const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { - parameter_index: 1, + parameter_index: regular_polygon::SidesInput::INDEX, gizmo_type: GizmoType::Dial, name: "Sides", min: Some(3.), @@ -89,7 +89,7 @@ const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { const STAR_GIZMOS: &[GizmoInfo] = &[ GizmoInfo { - parameter_index: 1, + parameter_index: star::SidesInput::INDEX, gizmo_type: GizmoType::Dial, name: "Points", min: Some(3.), @@ -97,7 +97,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ position_hint: PositionHint::BoundingBoxCenter, }, GizmoInfo { - parameter_index: 2, + parameter_index: star::Radius1Input::INDEX, gizmo_type: GizmoType::Slider, name: "Outer Radius", min: Some(0.), @@ -105,7 +105,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ position_hint: PositionHint::ParameterDerived, }, GizmoInfo { - parameter_index: 3, + parameter_index: star::Radius2Input::INDEX, gizmo_type: GizmoType::Slider, name: "Inner Radius", min: Some(0.), @@ -116,7 +116,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ const ARC_GIZMOS: &[GizmoInfo] = &[ GizmoInfo { - parameter_index: 1, + parameter_index: arc::RadiusInput::INDEX, gizmo_type: GizmoType::Slider, name: "Radius", min: Some(0.), @@ -124,7 +124,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ position_hint: PositionHint::ParameterDerived, }, GizmoInfo { - parameter_index: 2, + parameter_index: arc::StartAngleInput::INDEX, gizmo_type: GizmoType::Angle, name: "Start Angle", min: None, @@ -132,7 +132,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ position_hint: PositionHint::ParameterDerived, }, GizmoInfo { - parameter_index: 3, + parameter_index: arc::SweepAngleInput::INDEX, gizmo_type: GizmoType::Angle, name: "Sweep Angle", min: None, @@ -171,7 +171,7 @@ const SPIRAL_GIZMOS: &[GizmoInfo] = &[ // Only the radius slider: the heart's many shaping parameters (cleavage, lobes, shoulders, point) // are fine-tuned via the Properties panel, while the overall size reads naturally as a canvas handle. const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { - parameter_index: 1, + parameter_index: heart::RadiusInput::INDEX, gizmo_type: GizmoType::Slider, name: "Radius", min: Some(0.), @@ -197,7 +197,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ position_hint: PositionHint::BoundingBoxCorner, }, GizmoInfo { - parameter_index: grid::SpacingInput::::INDEX, + parameter_index: grid::SpacingInput::INDEX, gizmo_type: GizmoType::Position, name: "Spacing", min: Some(0.), diff --git a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs index cca9090626..6a8e5813ec 100644 --- a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -55,7 +55,7 @@ impl Heart { } responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, 1), + input_connector: InputConnector::node(node_id, graphene_std::vector::generator_nodes::heart::RadiusInput), input: NodeInput::value(TaggedValue::F64(radius), false), }); diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs index 33c6265cee..fdab81af7a 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs @@ -183,6 +183,79 @@ pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius polyline_bezpath(positions, true) } +/// Proportional controls for [`heart_bezpath`]. Lengths are fractions of the heart's radius and angles are +/// in radians, so a heart keeps its shape at any size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HeartProportions { + /// How far the top V dips below the upper bound of the heart. + pub cleavage_depth: f64, + /// Half-angle of the top V. Zero collapses the V into a smooth join. + pub cleavage_angle: f64, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + pub lobe_fullness: f64, + /// Vertical position of the side anchor (positive raises the shoulder). + pub shoulder_height: f64, + /// Horizontal position of the side anchor. + pub shoulder_width: f64, + /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. + pub shoulder_tilt: f64, + /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. + pub upper_curvature: f64, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + pub lower_curvature: f64, + /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. + pub point_sharpness: f64, + /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. + pub taper_length: f64, +} + +/// Constructs a heart from a `radius` and a set of proportional controls. The path is closed and runs +/// clockwise from the top cusp: top, right shoulder, bottom point, left shoulder. The two cusps are sharp +/// joins; the shoulders are G1-continuous. The left half is a mirror of the right, so the shape is always +/// symmetric about the vertical axis through `center`. +pub fn heart_bezpath(center: DVec2, radius: f64, proportions: HeartProportions) -> BezPath { + let HeartProportions { + cleavage_depth, + cleavage_angle, + lobe_fullness, + shoulder_height, + shoulder_width, + shoulder_tilt, + upper_curvature, + lower_curvature, + point_sharpness, + taper_length, + } = proportions; + + // Anchors for the right half plus the two y-axis cusps, in normalized coordinates (y points downward). + let top = DVec2::new(0., -1. + cleavage_depth); + let shoulder = DVec2::new(shoulder_width, -shoulder_height); + let bottom = DVec2::new(0., 1.); + + // Unit tangent directions, all measured from the upward vertical. + let top_direction = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); + let bottom_direction = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); + let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); + + // Cubic Bezier control points for the right half. + let top_out = top + top_direction * lobe_fullness; + let shoulder_in = shoulder + shoulder_up * upper_curvature; + let shoulder_out = shoulder - shoulder_up * lower_curvature; + let bottom_in = bottom + bottom_direction * taper_length; + + let place = |point: DVec2| center + point * radius; + let mirror = |point: DVec2| DVec2::new(-point.x, point.y); + + let anchors = [ + Anchor::new(place(top), Some(place(mirror(top_out))), Some(place(top_out))), + Anchor::new(place(shoulder), Some(place(shoulder_in)), Some(place(shoulder_out))), + Anchor::new(place(bottom), Some(place(bottom_in)), Some(place(mirror(bottom_in)))), + Anchor::new(place(mirror(shoulder)), Some(place(mirror(shoulder_out))), Some(place(mirror(shoulder_in)))), + ]; + + bezpath_from_anchors(&anchors, true) +} + /// Constructs a line from `point1` to `point2`. pub fn line_bezpath(point1: DVec2, point2: DVec2) -> BezPath { polyline_bezpath([point1, point2], false) diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 660b61ecfb..7a9e835393 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -231,47 +231,24 @@ fn heart( #[hard(0..1.2)] taper_length: Item, ) -> Item { - let radius = *radius.element(); - let cleavage_depth = *cleavage_depth.element(); - let lobe_fullness = *lobe_fullness.element(); - let shoulder_height = *shoulder_height.element(); - let shoulder_width = *shoulder_width.element(); - let upper_curvature = *upper_curvature.element(); - let lower_curvature = *lower_curvature.element(); - let taper_length = *taper_length.element(); - let cleavage_angle = cleavage_angle.element().to_radians(); - let point_sharpness = point_sharpness.element().to_radians(); - let shoulder_tilt = shoulder_tilt.element().to_radians(); - - // Anchor points for the right half plus the y-axis cusps, in normalized coordinates (y points downward). - let top = DVec2::new(0., -1. + cleavage_depth); - let shoulder = DVec2::new(shoulder_width, -shoulder_height); - let bottom = DVec2::new(0., 1.); - - // Unit tangent directions, all measured from the upward vertical. - let top_dir = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); - let bottom_dir_out = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); - let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); - let shoulder_down = -shoulder_up; - - // Cubic Bezier control points for the right half. - let c1 = top + top_dir * lobe_fullness; - let c2 = shoulder + shoulder_up * upper_curvature; - let c3 = shoulder + shoulder_down * lower_curvature; - let c4 = bottom + bottom_dir_out * taper_length; - - let mirror = |p: DVec2| DVec2::new(-p.x, p.y); - - // Closed clockwise path: T → S → B → S' → T. Joins at T and B are sharp; joins at the shoulders are G1. - let manipulator_groups = [ - subpath::ManipulatorGroup::new(top * radius, Some(mirror(c1) * radius), Some(c1 * radius)), - subpath::ManipulatorGroup::new(shoulder * radius, Some(c2 * radius), Some(c3 * radius)), - subpath::ManipulatorGroup::new(bottom * radius, Some(c4 * radius), Some(mirror(c4) * radius)), - subpath::ManipulatorGroup::new(mirror(shoulder) * radius, Some(mirror(c3) * radius), Some(mirror(c2) * radius)), - ] - .to_vec(); - - Item::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + let bezpath = shapes::heart_bezpath( + DVec2::ZERO, + *radius.element(), + shapes::HeartProportions { + cleavage_depth: *cleavage_depth.element(), + cleavage_angle: cleavage_angle.element().to_radians(), + lobe_fullness: *lobe_fullness.element(), + shoulder_height: *shoulder_height.element(), + shoulder_width: *shoulder_width.element(), + shoulder_tilt: shoulder_tilt.element().to_radians(), + upper_curvature: *upper_curvature.element(), + lower_curvature: *lower_curvature.element(), + point_sharpness: point_sharpness.element().to_radians(), + taper_length: *taper_length.element(), + }, + ); + + Item::new_from_element(Vector::from_bezpath(bezpath)) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. From cb64b56c51f79fb5c09b35df27dc8fda19e45c09 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 22 Aug 2026 00:17:01 +0530 Subject: [PATCH 09/24] Cover the ported heart geometry with tests The port from the deleted subpath API to BezPath was a manual translation of anchor and handle positions, and nothing else in the tree exercises the heart's geometry. These pin the properties the translation could plausibly have broken: segment count and closure, mirror symmetry about the vertical axis, linear scaling with radius, and honoring the center argument. --- .../src/vector/algorithms/shapes.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs index fdab81af7a..63fb23dd0e 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs @@ -416,3 +416,80 @@ fn archimedean_spiral_arc_length_origin(theta: f64, a: f64, b: f64) -> f64 { let sqrt_term = (r * r + b * b).sqrt(); (r * sqrt_term + b * b * ((r + sqrt_term).ln())) / (2. * b) } + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::{PathEl, Shape}; + + fn default_heart() -> HeartProportions { + HeartProportions { + cleavage_depth: 0.2, + cleavage_angle: 45_f64.to_radians(), + lobe_fullness: 0.55, + shoulder_height: 0.5, + shoulder_width: 1., + shoulder_tilt: 0., + upper_curvature: 0.55, + lower_curvature: 1., + point_sharpness: 30_f64.to_radians(), + taper_length: 0.7, + } + } + + #[test] + fn heart_is_a_closed_path_of_four_curves() { + let bezpath = heart_bezpath(DVec2::ZERO, 50., default_heart()); + let elements: Vec<_> = bezpath.elements().to_vec(); + + assert!(matches!(elements.first(), Some(PathEl::MoveTo(_)))); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + assert_eq!(elements.iter().filter(|element| matches!(element, PathEl::CurveTo(..))).count(), 4); + } + + #[test] + fn heart_is_symmetric_about_the_vertical_axis() { + let bezpath = heart_bezpath(DVec2::ZERO, 50., default_heart()); + + // Every point on the path must have a mirrored twin, since the left half is built by mirroring the right. + let points: Vec = bezpath + .elements() + .iter() + .flat_map(|element| match element { + PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![DVec2::new(p.x, p.y)], + PathEl::QuadTo(a, b) => vec![DVec2::new(a.x, a.y), DVec2::new(b.x, b.y)], + PathEl::CurveTo(a, b, c) => vec![DVec2::new(a.x, a.y), DVec2::new(b.x, b.y), DVec2::new(c.x, c.y)], + PathEl::ClosePath => vec![], + }) + .collect(); + + for point in &points { + let mirrored = DVec2::new(-point.x, point.y); + assert!(points.iter().any(|other| other.distance(mirrored) < 1e-9), "no mirrored counterpart for {point:?}"); + } + } + + #[test] + fn heart_scales_linearly_with_radius() { + let small = heart_bezpath(DVec2::ZERO, 1., default_heart()); + let large = heart_bezpath(DVec2::ZERO, 50., default_heart()); + + let small_box = small.bounding_box(); + let large_box = large.bounding_box(); + + assert!((large_box.width() - small_box.width() * 50.).abs() < 1e-9); + assert!((large_box.height() - small_box.height() * 50.).abs() < 1e-9); + } + + #[test] + fn heart_respects_its_center() { + let origin = heart_bezpath(DVec2::ZERO, 20., default_heart()); + let offset = heart_bezpath(DVec2::new(100., -40.), 20., default_heart()); + + let origin_box = origin.bounding_box(); + let offset_box = offset.bounding_box(); + + assert!((offset_box.center().x - (origin_box.center().x + 100.)).abs() < 1e-9); + assert!((offset_box.center().y - (origin_box.center().y - 40.)).abs() < 1e-9); + } +} From 8ba6db9c88abcab3974da43929eaf1ef4eaa0fd3 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 22 Aug 2026 18:15:42 +0530 Subject: [PATCH 10/24] Give the generic gizmos an escape hatch for shape-specific behavior Every parametric shape's gizmo shares the same mechanics -- hit-test a handle, run a hover/drag state machine, draw the handle, write the input -- which is what the generic gizmos already replace. But three behaviors in the hand-written handlers genuinely depend on a node's geometry and cannot be expressed as registry data: - A star's snap radii fall out of its side count and its *other* radius. - A spiral's turns and outer radius have to move together, or the spiral changes tightness as you drag it. - A star previews its outline and spokes while dragging, not just the handle. `GizmoBehavior` carries those as optional functions supplied by the shape, so the registry stays a declarative table and the geometry-specific math stays with the geometry. All fifteen existing declarations opt out via `GizmoBehavior::NONE`; nothing changes for them. Snap resolution is extracted as `nearest_snap_target` so it can be tested without a live document. Targets are captured when the gizmo is first hovered rather than recomputed per frame, since they depend on parameters that are themselves in flux during a drag. --- .../generic_gizmos/generic_slider_gizmo.rs | 97 ++++++++++++++++++- .../gizmos/gizmo_registry.rs | 62 +++++++++++- 2 files changed, 154 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 5727aded78..713cb7f5e3 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -5,7 +5,7 @@ //! it knows nothing about the specific node it edits beyond the node id, the parameter index, and //! the registry's [`GizmoInfo`]. This is what lets any node opt into a slider with zero custom code. -use crate::consts::GIZMO_HIDE_THRESHOLD; +use crate::consts::{GIZMO_HIDE_THRESHOLD, POINT_RADIUS_HANDLE_SNAP_THRESHOLD}; use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::message::Message; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -13,7 +13,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoInfo, PositionHint}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, PositionHint}; use glam::DVec2; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; @@ -43,6 +43,10 @@ pub struct GenericSliderGizmo { state: GenericSliderState, /// The parameter value captured when the drag began, used as the clamping/anchor reference. initial_value: f64, + /// Values this drag should snap to, resolved once when the gizmo is first hovered. They depend on the + /// layer's *other* parameters, so they are captured alongside `initial_value` rather than recomputed + /// per frame while the value being dragged is already in flux. + snap_targets: Vec, } impl GenericSliderGizmo { @@ -54,6 +58,7 @@ impl GenericSliderGizmo { info, state: GenericSliderState::Inactive, initial_value: 0., + snap_targets: Vec::new(), } } @@ -67,6 +72,7 @@ impl GenericSliderGizmo { pub fn cleanup(&mut self) { self.state = GenericSliderState::Inactive; + self.snap_targets.clear(); } /// Begin a drag if currently hovered. @@ -86,6 +92,14 @@ impl GenericSliderGizmo { } } + fn context<'a>(&self, document: &'a DocumentMessageHandler) -> GizmoContext<'a> { + GizmoContext { + layer: self.layer, + document, + parameter: self.parameter(), + } + } + fn current_value(&self, document: &DocumentMessageHandler) -> Option { read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index) } @@ -132,6 +146,10 @@ impl GenericSliderGizmo { self.state = GenericSliderState::Hover; self.initial_value = value; + self.snap_targets = match self.info.behavior.snap_targets { + Some(targets) => targets(&self.context(document)), + None => Vec::new(), + }; responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); } @@ -139,6 +157,7 @@ impl GenericSliderGizmo { pub fn exit_hover(&mut self, responses: &mut VecDeque) { if self.state == GenericSliderState::Hover { self.state = GenericSliderState::Inactive; + self.snap_targets.clear(); responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); } } @@ -156,15 +175,33 @@ impl GenericSliderGizmo { value = -value; } - value = self.clamp(value); + value = self.snap(self.clamp(value)); responses.add(NodeGraphMessage::SetInput { input_connector: InputConnector::node(self.node_id, self.parameter()), input: NodeInput::value(TaggedValue::F64(value), false), }); + + // Parameters that are only meaningful in combination are written in the same batch, so the graph + // never evaluates a half-updated shape. + if let Some(coupled_writes) = self.info.behavior.coupled_writes { + for (parameter, coupled_value) in coupled_writes(&self.context(document), value) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(self.node_id, parameter), + input: NodeInput::value(coupled_value, false), + }); + } + } + responses.add(NodeGraphMessage::RunDocumentGraph); } + /// Pull the value onto the nearest snap target within the threshold. Returns the value unchanged when + /// nothing is in range. + fn snap(&self, value: f64) -> f64 { + nearest_snap_target(value, &self.snap_targets, POINT_RADIUS_HANDLE_SNAP_THRESHOLD).unwrap_or(value) + } + fn clamp(&self, value: f64) -> f64 { let mut value = value; if let Some(min) = self.info.min { @@ -193,6 +230,10 @@ impl GenericSliderGizmo { overlay_context.line(center, handle, None, None); overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None); + + if let Some(overlay) = self.info.behavior.overlay { + overlay(&self.context(document), overlay_context); + } } pub fn mouse_cursor_icon(&self) -> Option { @@ -202,3 +243,53 @@ impl GenericSliderGizmo { } } } + +/// The snap target closest to `value`, if any lies within `threshold`. Ties go to the earlier target, so a +/// shape can express priority through the order it returns them in. +fn nearest_snap_target(value: f64, targets: &[f64], threshold: f64) -> Option { + targets + .iter() + .copied() + .map(|target| (target, (target - value).abs())) + .filter(|(_, distance)| *distance < threshold) + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(target, _)| target) +} + +#[cfg(test)] +mod tests { + use super::nearest_snap_target; + + #[test] + fn no_targets_never_snaps() { + assert_eq!(nearest_snap_target(42., &[], 8.), None); + } + + #[test] + fn snaps_to_a_target_inside_the_threshold() { + assert_eq!(nearest_snap_target(48., &[50., 100.], 8.), Some(50.)); + } + + #[test] + fn ignores_targets_outside_the_threshold() { + assert_eq!(nearest_snap_target(40., &[50., 100.], 8.), None); + } + + #[test] + fn picks_the_nearest_of_several_candidates() { + assert_eq!(nearest_snap_target(52., &[50., 55., 100.], 8.), Some(50.)); + assert_eq!(nearest_snap_target(54., &[50., 55., 100.], 8.), Some(55.)); + } + + #[test] + fn ties_go_to_the_earlier_target() { + // A shape returns its most important snap targets first, so an exact tie must not reorder them. + assert_eq!(nearest_snap_target(50., &[45., 55.], 8.), Some(45.)); + } + + #[test] + fn handles_negative_values() { + // Radii can be negative, and a negative radius snaps against negative targets. + assert_eq!(nearest_snap_target(-48., &[-50., 50.], 8.), Some(-50.)); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index fb3c9816bf..dbc2c426f9 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -12,10 +12,14 @@ //! //! See `GENERIC_GIZMOS.md` (next to this file) for a full walkthrough. +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::prelude::DocumentMessageHandler; use graph_craft::ProtoNodeIdentifier; -use graphene_std::NodeParameter; +use graph_craft::document::value::TaggedValue; use graphene_std::vector::generator_nodes; use graphene_std::vector::generator_nodes::{arc, circle, grid, heart, regular_polygon, spiral, star}; +use graphene_std::{NodeParameter, ParameterRef}; /// The kind of interactive control a gizmo presents, which also determines the underlying /// [`TaggedValue`](graph_craft::document::value::TaggedValue) type of the parameter it edits. @@ -47,9 +51,46 @@ pub enum PositionHint { ParameterDerived, } +/// What a shape-specific hook is given to work with: the layer being edited, the document to read the +/// node's other parameters from, and which parameter the gizmo in question drives. +pub struct GizmoContext<'a> { + pub layer: LayerNodeIdentifier, + pub document: &'a DocumentMessageHandler, + pub parameter: ParameterRef, +} + +/// The escape hatch for nodes whose gizmo needs more than the generic mechanics. +/// +/// The generic layer always owns hit-testing, the hover/drag state machine, the handle overlay, and the +/// input write. A few behaviors genuinely depend on a node's geometry and cannot be expressed as data — +/// a star's snap radii fall out of its side count and its *other* radius; a spiral's turns and outer +/// radius have to move together or the spiral changes tightness as you drag. Those arrive here as +/// functions supplied by the shape, so the registry stays a table and the shape-specific math stays +/// with the shape. +#[derive(Clone, Copy, Debug)] +pub struct GizmoBehavior { + /// Values the drag should snap to, recomputed from the layer's current parameters at drag start. + pub snap_targets: Option Vec>, + /// Extra overlay drawn while this gizmo is hovered or dragged, for nodes that show more than the + /// bare handle (a star previews its outline and spokes, for instance). + pub overlay: Option, + /// Additional inputs to write alongside the dragged one, given the value the drag produced. Used by + /// parameters that are only meaningful in combination. + pub coupled_writes: Option Vec<(ParameterRef, TaggedValue)>>, +} + +impl GizmoBehavior { + /// A behavior with no hooks set, for `const` declarations that only need one of them. + pub const NONE: Self = Self { + snap_targets: None, + overlay: None, + coupled_writes: None, + }; +} + /// Describes a single gizmo-enabled parameter of a node: which input it edits, how it should be /// presented, and the constraints/positioning that apply. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] pub struct GizmoInfo { /// The index of the node input this gizmo edits. pub parameter_index: usize, @@ -63,6 +104,8 @@ pub struct GizmoInfo { pub max: Option, /// Where the gizmo's handle should be anchored. pub position_hint: PositionHint, + /// Shape-specific behavior, for the few nodes whose gizmo needs more than the generic mechanics. + pub behavior: GizmoBehavior, } // --- Per-node gizmo declarations ------------------------------------------------------------ @@ -73,6 +116,7 @@ const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { name: "Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }]; @@ -84,6 +128,7 @@ const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { name: "Sides", min: Some(3.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxCenter, }]; @@ -94,6 +139,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Points", min: Some(3.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxCenter, }, GizmoInfo { @@ -102,6 +148,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Outer Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -110,6 +157,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Inner Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, ]; @@ -121,6 +169,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ name: "Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -129,6 +178,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ name: "Start Angle", min: None, max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -137,6 +187,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ name: "Sweep Angle", min: None, max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, ]; @@ -148,6 +199,7 @@ const SPIRAL_GIZMOS: &[GizmoInfo] = &[ name: "Inner Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -156,6 +208,7 @@ const SPIRAL_GIZMOS: &[GizmoInfo] = &[ name: "Outer Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -164,6 +217,7 @@ const SPIRAL_GIZMOS: &[GizmoInfo] = &[ name: "Turns", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxEdge, }, ]; @@ -176,6 +230,7 @@ const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { name: "Radius", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }]; @@ -186,6 +241,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ name: "Columns", min: Some(1.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxCorner, }, GizmoInfo { @@ -194,6 +250,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ name: "Rows", min: Some(1.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxCorner, }, GizmoInfo { @@ -202,6 +259,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ name: "Spacing", min: Some(0.), max: None, + behavior: GizmoBehavior::NONE, position_hint: PositionHint::BoundingBoxCorner, }, ]; From 7932c397a88f9476856fa549f8c7eae8ad65404b Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 22 Aug 2026 18:23:02 +0530 Subject: [PATCH 11/24] Restore the polygon dial's spokes and outline, lost in the generic migration The hand-written `NumberOfPointsDial` drew a spoke per side and the shape's outline: the spokes at rest as a hint that the dial exists at all, and both while dragging so the count being edited stays legible. Moving the polygon to the generic dial dropped all of it, leaving a bare ring with no indication of what it controls. The overlay hook now runs in every state, including Inactive, since a resting affordance is exactly the case the shape wants to draw for. `GizmoContext` grows the cursor position, the interaction state, and the path editor's state, which between them are what the ported drawing code needs -- including standing down near an editable segment, so the hint never competes with path editing. `gizmo_behaviors` is the new home for this shape-specific half. It also carries the star's declarations, which are correct but not yet reachable: the star's radius handles sit on its vertices rather than at a single point on the +X axis, so they need a gizmo type the generic layer does not have yet. --- .../generic_gizmos/generic_dial_gizmo.rs | 26 ++- .../generic_gizmos/generic_slider_gizmo.rs | 22 +- .../gizmos/generic_gizmos/mod.rs | 22 +- .../gizmos/gizmo_behaviors.rs | 190 ++++++++++++++++++ .../gizmos/gizmo_registry.rs | 29 ++- .../tool/common_functionality/gizmos/mod.rs | 1 + 6 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs index 6326618e37..0868042254 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -14,7 +14,8 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_u32_input; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::GizmoInfo; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, GizmoState}; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; use glam::DVec2; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; @@ -91,6 +92,21 @@ impl GenericDialGizmo { } } + fn context<'a>(&self, document: &'a DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&'a ShapeState>) -> GizmoContext<'a> { + GizmoContext { + layer: self.layer, + document, + parameter: self.parameter(), + state: match self.state { + GenericDialState::Inactive => GizmoState::Inactive, + GenericDialState::Hover => GizmoState::Hover, + GenericDialState::Dragging => GizmoState::Dragging, + }, + mouse_position, + shape_editor, + } + } + fn current_value(&self, document: &DocumentMessageHandler) -> Option { read_u32_input(self.layer, document, &self.identifier, self.info.parameter_index) } @@ -117,7 +133,7 @@ impl GenericDialGizmo { /// Transition into the hovered state (no-op if already hovered or dragging), capturing the /// reference value because `handle_click` has no document access. - pub fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + pub fn enter_hover(&mut self, document: &DocumentMessageHandler, _mouse_position: DVec2, responses: &mut VecDeque) { if self.state != GenericDialState::Inactive { return; } @@ -157,7 +173,11 @@ impl GenericDialGizmo { /// Draw the dial as a grabbable handle at the layer origin: an outer ring (the hit target) plus /// a filled center dot so it reads as draggable. - pub fn overlays(&self, document: &DocumentMessageHandler, _mouse_position: DVec2, overlay_context: &mut OverlayContext) { + pub fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) { + if let Some(overlay) = self.info.behavior.overlay { + overlay(&self.context(document, mouse_position, shape_editor), overlay_context); + } + if self.state == GenericDialState::Inactive { return; } diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 713cb7f5e3..f5a1aef2a6 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -13,7 +13,8 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, PositionHint}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, GizmoState, PositionHint}; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; use glam::DVec2; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; @@ -92,11 +93,18 @@ impl GenericSliderGizmo { } } - fn context<'a>(&self, document: &'a DocumentMessageHandler) -> GizmoContext<'a> { + fn context<'a>(&self, document: &'a DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&'a ShapeState>) -> GizmoContext<'a> { GizmoContext { layer: self.layer, document, parameter: self.parameter(), + state: match self.state { + GenericSliderState::Inactive => GizmoState::Inactive, + GenericSliderState::Hover => GizmoState::Hover, + GenericSliderState::Dragging => GizmoState::Dragging, + }, + mouse_position, + shape_editor, } } @@ -138,7 +146,7 @@ impl GenericSliderGizmo { /// Transition into the hovered state (no-op if already hovered or dragging). Capturing the /// reference value here is necessary because `handle_click` (which starts the drag) has no /// access to the document. - pub fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + pub fn enter_hover(&mut self, document: &DocumentMessageHandler, mouse_position: DVec2, responses: &mut VecDeque) { if self.state != GenericSliderState::Inactive { return; } @@ -147,7 +155,7 @@ impl GenericSliderGizmo { self.state = GenericSliderState::Hover; self.initial_value = value; self.snap_targets = match self.info.behavior.snap_targets { - Some(targets) => targets(&self.context(document)), + Some(targets) => targets(&self.context(document, mouse_position, None)), None => Vec::new(), }; responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); @@ -185,7 +193,7 @@ impl GenericSliderGizmo { // Parameters that are only meaningful in combination are written in the same batch, so the graph // never evaluates a half-updated shape. if let Some(coupled_writes) = self.info.behavior.coupled_writes { - for (parameter, coupled_value) in coupled_writes(&self.context(document), value) { + for (parameter, coupled_value) in coupled_writes(&self.context(document, input.mouse.position, None), value) { responses.add(NodeGraphMessage::SetInput { input_connector: InputConnector::node(self.node_id, parameter), input: NodeInput::value(coupled_value, false), @@ -214,7 +222,7 @@ impl GenericSliderGizmo { } /// Draw the handle dot, plus a guide line from the layer origin while hovered or dragging. - pub fn overlays(&self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { + pub fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) { if self.state == GenericSliderState::Inactive { return; } @@ -232,7 +240,7 @@ impl GenericSliderGizmo { overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None); if let Some(overlay) = self.info.behavior.overlay { - overlay(&self.context(document), overlay_context); + overlay(&self.context(document, mouse_position, shape_editor), overlay_context); } } diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs index a5035ae9de..4acd128812 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -81,10 +81,10 @@ impl GenericGizmo { } } - fn enter_hover(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + fn enter_hover(&mut self, document: &DocumentMessageHandler, mouse_position: DVec2, responses: &mut VecDeque) { match self { - Self::Slider(g) => g.enter_hover(document, responses), - Self::Dial(g) => g.enter_hover(document, responses), + Self::Slider(g) => g.enter_hover(document, mouse_position, responses), + Self::Dial(g) => g.enter_hover(document, mouse_position, responses), } } @@ -109,10 +109,10 @@ impl GenericGizmo { } } - fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, overlay_context: &mut OverlayContext) { + fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) { match self { - Self::Slider(g) => g.overlays(document, overlay_context), - Self::Dial(g) => g.overlays(document, mouse_position, overlay_context), + Self::Slider(g) => g.overlays(document, mouse_position, shape_editor, overlay_context), + Self::Dial(g) => g.overlays(document, mouse_position, shape_editor, overlay_context), } } @@ -202,7 +202,7 @@ impl ShapeGizmoHandler for GenericGizmoManager { let winner = self.closest_hover_candidate(mouse_position, document); for (index, gizmo) in self.gizmos.iter_mut().enumerate() { if Some(index) == winner { - gizmo.enter_hover(document, responses); + gizmo.enter_hover(document, mouse_position, responses); } else { gizmo.exit_hover(responses); } @@ -228,12 +228,12 @@ impl ShapeGizmoHandler for GenericGizmoManager { document: &DocumentMessageHandler, _selected_shape_layers: Option, _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, + shape_editor: &mut &mut ShapeState, mouse_position: DVec2, overlay_context: &mut OverlayContext, ) { for gizmo in &self.gizmos { - gizmo.overlays(document, mouse_position, overlay_context); + gizmo.overlays(document, mouse_position, Some(shape_editor), overlay_context); } } @@ -241,13 +241,13 @@ impl ShapeGizmoHandler for GenericGizmoManager { &self, document: &DocumentMessageHandler, _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, + shape_editor: &mut &mut ShapeState, mouse_position: DVec2, overlay_context: &mut OverlayContext, ) { for gizmo in &self.gizmos { if gizmo.is_dragging() { - gizmo.overlays(document, mouse_position, overlay_context); + gizmo.overlays(document, mouse_position, Some(shape_editor), overlay_context); } } } diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs new file mode 100644 index 0000000000..76783dd1a2 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -0,0 +1,190 @@ +//! # Gizmo behaviors +//! +//! The shape-specific half of the generic gizmo system, and the only place node geometry is allowed to +//! leak into it. +//! +//! The [generic gizmos](super::generic_gizmos) own everything that is the same for every shape: the +//! hover/drag state machine, hit-testing, the handle overlay, and writing the node input. A handful of +//! behaviors are irreducibly shape-specific, though — a star's snap radii are a function of its side +//! count and its *other* radius, and no amount of registry data expresses that. Those live here as plain +//! functions, referenced from the [registry](super::gizmo_registry) table. + +use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoBehavior, GizmoContext, GizmoState}; +use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::shapes::shape_utility::{ + extract_polygon_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, +}; +use glam::{DAffine2, DVec2}; +use graph_craft::document::value::TaggedValue; +use graphene_std::ParameterRef; +use graphene_std::vector::generator_nodes::star; +use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_4, PI, SQRT_2, TAU}; + +/// The star's sides dial: previews the shape it is about to change. +pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(star_sides_overlay), + coupled_writes: None, +}; + +/// Either of the star's radius handles: snaps to the radii where the star's points line up, and previews +/// the outline while being dragged. +pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { + snap_targets: Some(star_snap_radii), + overlay: Some(star_radius_overlay), + coupled_writes: None, +}; + +/// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. +pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(polygon_sides_overlay), + coupled_writes: None, +}; + +/// The radii at which dragging one of a star's radius handles makes its points line up: the value where +/// the tips sit at 90°, the mirrored case where the handle overtakes the other radius, and then every +/// radius that puts a vertex collinear with one of its neighbors. +/// +/// All of them are derived from the side count and the radius that is *not* being dragged, which is why +/// this cannot be a registry constant. +fn star_snap_radii(context: &GizmoContext) -> Vec { + let mut snap_radii = Vec::new(); + + let Some(parameters) = NodeGraphLayer::new(context.layer, &context.document.network_interface).find_node_parameters(star::IDENTIFIER) else { + return snap_radii; + }; + + let (Some(&TaggedValue::F64(radius_1)), Some(&TaggedValue::F64(radius_2))) = (parameters.value(star::Radius1Input), parameters.value(star::Radius2Input)) else { + return snap_radii; + }; + let Some(&TaggedValue::U32(sides)) = parameters.value(star::SidesInput) else { + return snap_radii; + }; + + let other_radius = if context.parameter == ParameterRef::from(star::Radius2Input) { radius_1 } else { radius_2 }; + + // With one radius negative and the other positive the star is inside out, and none of the alignments + // below describe a shape the user can see, so there is nothing worth snapping to. + if (radius_1.signum() * radius_2.signum()).is_sign_negative() { + return snap_radii; + } + + let sign = if radius_1.is_sign_negative() && radius_2.is_sign_negative() { -1. } else { 1. }; + + // The radius that puts the star's points at 90°, and the same alignment reached from the other side. + let angle = (FRAC_PI_4 * 3. - PI / (sides as f64)).sin(); + snap_radii.push((other_radius.abs() * sign / angle) * FRAC_1_SQRT_2); + snap_radii.push(other_radius.abs() * sign * angle * SQRT_2); + + // Each radius that makes a vertex collinear with one of its neighbors, walking outward. + for i in 1..sides { + let sides = sides as f64; + let i = i as f64; + let denominator = 2. * ((PI * (i - 1.)) / sides).cos() * ((PI * i) / sides).sin(); + let factor = ((2. * PI * i) / sides).sin() / denominator; + + if factor < 0. { + break; + } + if other_radius.abs() * factor > 1e-6 { + snap_radii.push(other_radius.abs() * sign * factor); + } + snap_radii.push((other_radius.abs() * sign) / factor); + } + + snap_radii +} + +/// Show the star's outline whenever one of its radius handles is in play, so the user can see the whole +/// shape responding rather than just the handle they are holding. +fn star_radius_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + if context.state == GizmoState::Inactive { + return; + } + star_outline(Some(context.layer), context.document, overlay_context); +} + +fn star_sides_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let Some((sides, radius_1, radius_2)) = extract_star_parameters(Some(context.layer), context.document) else { + return; + }; + let radius = radius_1.max(radius_2); + let viewport = context.document.metadata().transform_to_viewport(context.layer); + + if context.state == GizmoState::Inactive { + // At rest the spokes are only a hint that the dial is there, so they appear once the cursor is + // inside the star, and stand down near an editable segment where they would compete with the path + // editor's own overlays. + if over_editable_segment(context) { + return; + } + let center = viewport.transform_point2(DVec2::ZERO); + let outermost = star_vertex_position(viewport, 0, sides, radius_1, radius_2); + if !inside_star(viewport, sides, radius_1, radius_2, context.mouse_position) || outermost.distance(center) <= GIZMO_HIDE_THRESHOLD { + return; + } + } else { + star_outline(Some(context.layer), context.document, overlay_context); + } + + draw_spokes(viewport, sides, radius, context.state, overlay_context); +} + +fn polygon_sides_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let Some((sides, radius)) = extract_polygon_parameters(Some(context.layer), context.document) else { + return; + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + + if context.state == GizmoState::Inactive { + if over_editable_segment(context) { + return; + } + let center = viewport.transform_point2(DVec2::ZERO); + let outermost = polygon_vertex_position(viewport, 0, sides, radius); + if !inside_polygon(viewport, sides, radius, context.mouse_position) || outermost.distance(center) <= GIZMO_HIDE_THRESHOLD { + return; + } + } else { + polygon_outline(Some(context.layer), context.document, overlay_context); + } + + draw_spokes(viewport, sides, radius, context.state, overlay_context); +} + +/// True when the cursor is close enough to one of this layer's segments that the path editor owns it. +fn over_editable_segment(context: &GizmoContext) -> bool { + let Some(shape_editor) = context.shape_editor else { return false }; + + shape_editor + .upper_closest_segment(&context.document.network_interface, context.mouse_position, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD) + .is_some_and(|segment| segment.layer() == context.layer) +} + +/// One short line per side, radiating from the center. They lengthen once the dial is engaged, which is +/// what makes the count being edited legible while dragging. +fn draw_spokes(viewport: DAffine2, sides: u32, radius: f64, state: GizmoState, overlay_context: &mut OverlayContext) { + let center = viewport.transform_point2(DVec2::ZERO); + let length = match state { + GizmoState::Inactive => NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, + _ => NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH * NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, + }; + + for i in 0..sides { + let angle = ((i as f64) * TAU) / (sides as f64); + let point = viewport.transform_point2(DVec2::new(radius * angle.sin(), -radius * angle.cos())); + + let Some(direction) = (point - center).try_normalize() else { continue }; + + // Once the shape is this small on screen the spokes are longer than the shape itself, which reads + // as noise rather than as a control. + if point.distance(center) < GIZMO_HIDE_THRESHOLD { + return; + } + + overlay_context.line(center, center + direction * length, None, None); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index dbc2c426f9..22200b6afa 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -15,6 +15,9 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::DocumentMessageHandler; +use crate::messages::tool::common_functionality::gizmos::gizmo_behaviors; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use glam::DVec2; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::TaggedValue; use graphene_std::vector::generator_nodes; @@ -51,12 +54,30 @@ pub enum PositionHint { ParameterDerived, } +/// How the user is currently engaging a gizmo. Hooks receive it so a shape can draw one thing as a +/// resting affordance and another mid-drag, the way the hand-written handlers do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GizmoState { + /// Not hovered: the gizmo is idle, but a shape may still want a subtle hint on screen. + Inactive, + /// The cursor is on the handle. + Hover, + /// A drag is in progress. + Dragging, +} + /// What a shape-specific hook is given to work with: the layer being edited, the document to read the /// node's other parameters from, and which parameter the gizmo in question drives. pub struct GizmoContext<'a> { pub layer: LayerNodeIdentifier, pub document: &'a DocumentMessageHandler, pub parameter: ParameterRef, + pub state: GizmoState, + /// Where the cursor is, for hints that track the pointer rather than the handle. + pub mouse_position: DVec2, + /// The path editor's state, available while overlays are being drawn. A shape uses it to stand down + /// when the cursor is over an editable segment, so a resting hint never competes with path editing. + pub shape_editor: Option<&'a ShapeState>, } /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. @@ -128,7 +149,7 @@ const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { name: "Sides", min: Some(3.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::POLYGON_SIDES, position_hint: PositionHint::BoundingBoxCenter, }]; @@ -139,7 +160,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Points", min: Some(3.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::STAR_SIDES, position_hint: PositionHint::BoundingBoxCenter, }, GizmoInfo { @@ -148,7 +169,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Outer Radius", min: Some(0.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::STAR_RADIUS, position_hint: PositionHint::ParameterDerived, }, GizmoInfo { @@ -157,7 +178,7 @@ const STAR_GIZMOS: &[GizmoInfo] = &[ name: "Inner Radius", min: Some(0.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::STAR_RADIUS, position_hint: PositionHint::ParameterDerived, }, ]; diff --git a/editor/src/messages/tool/common_functionality/gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/mod.rs index bc8437929e..9e72fe329d 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/mod.rs @@ -1,4 +1,5 @@ pub mod generic_gizmos; +pub mod gizmo_behaviors; pub mod gizmo_manager; pub mod gizmo_registry; pub mod shape_gizmos; From 8bbc2a585ed332875a8e87be8733bd41c2da076a Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 01:56:21 +0530 Subject: [PATCH 12/24] Migrate the Star gizmos to the generic registry system The star's radius is not a slider. Its handles sit on the shape's own points -- `radius_1` grabbable at every outer vertex, `radius_2` at every inner one -- and the drag runs out along whichever point the user took hold of, not along a fixed axis. The generic slider assumed a single handle on the local +X axis, which is why the star could not migrate with the circle and the polygon. So the slider now asks the shape where its parameter can be grabbed. The default is unchanged and still a single handle `value` out along +X, which is what a circle, a heart, and a spiral all want. A shape that puts handles on its geometry returns the whole set, and the drag projects onto the ray through the one that was grabbed. `handle_index` records which, so the overlay can draw the right ray. With that, the star's remaining behavior is expressible through the hooks: snapping to the radii where its points line up, ticks marking them, the ray extended across the viewport, and the outline of the shape being reshaped. Deletes `PointRadiusHandle` and `NumberOfPointsDial`, whose last user this was, along with the `ShapeGizmoHandlers::Star` variant. Two deliberate differences from the hand-written version: the drag projects in the layer's local space rather than mixing local deltas with viewport-space directions, which is what it meant to do and now also holds under rotation; and the red alignment guides drawn at the moment of snapping are not reproduced -- the ticks still mark every snap radius. --- .../generic_gizmos/generic_dial_gizmo.rs | 1 + .../generic_gizmos/generic_slider_gizmo.rs | 82 ++- .../gizmos/gizmo_behaviors.rs | 61 ++- .../gizmos/gizmo_manager.rs | 20 +- .../gizmos/gizmo_registry.rs | 10 + .../gizmos/shape_gizmos/mod.rs | 2 - .../shape_gizmos/number_of_points_dial.rs | 216 -------- .../shape_gizmos/point_radius_handle.rs | 478 ------------------ .../common_functionality/shapes/star_shape.rs | 92 ---- 9 files changed, 134 insertions(+), 828 deletions(-) delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/number_of_points_dial.rs delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/point_radius_handle.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs index 0868042254..937196a9dd 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -104,6 +104,7 @@ impl GenericDialGizmo { }, mouse_position, shape_editor, + handle_index: 0, } } diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index f5a1aef2a6..9aa3c35f9b 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -48,6 +48,9 @@ pub struct GenericSliderGizmo { /// layer's *other* parameters, so they are captured alongside `initial_value` rather than recomputed /// per frame while the value being dragged is already in flux. snap_targets: Vec, + /// Which grab point the user took hold of, indexing `handle_positions`. Zero for the common case of a + /// parameter with a single handle. + handle_index: usize, } impl GenericSliderGizmo { @@ -60,6 +63,7 @@ impl GenericSliderGizmo { state: GenericSliderState::Inactive, initial_value: 0., snap_targets: Vec::new(), + handle_index: 0, } } @@ -105,6 +109,7 @@ impl GenericSliderGizmo { }, mouse_position, shape_editor, + handle_index: self.handle_index, } } @@ -112,18 +117,29 @@ impl GenericSliderGizmo { read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index) } - /// The handle's anchor point, in the layer's local coordinate space, derived from the current - /// parameter value and the registry's position hint. - fn handle_position_local(&self, value: f64) -> DVec2 { - match self.info.position_hint { - // A length-like parameter: place the handle that far out along the local +X axis. - PositionHint::ParameterDerived => DVec2::new(value.abs(), 0.), - // Generic fall-backs map the value onto the local +X axis as well; bounding-box-aware - // hints are refined as more node types adopt the slider. - _ => DVec2::new(value.abs(), 0.), + /// Every point in the layer's local space where this parameter can be grabbed. + /// + /// Shapes that place their handles on their own geometry supply them; everything else gets the single + /// default handle, sitting `value` out along the local +X axis. + fn handle_positions(&self, document: &DocumentMessageHandler, value: f64) -> Vec { + match self.info.behavior.handle_positions { + Some(positions) => positions(&self.context(document, DVec2::ZERO, None), value), + None => vec![match self.info.position_hint { + // A length-like parameter: place the handle that far out along the local +X axis. + PositionHint::ParameterDerived => DVec2::new(value.abs(), 0.), + // Generic fall-backs map the value onto the local +X axis as well; bounding-box-aware + // hints are refined as more node types adopt the slider. + _ => DVec2::new(value.abs(), 0.), + }], } } + /// The grab point currently in play, in local space. + fn active_handle_local(&self, document: &DocumentMessageHandler, value: f64) -> Option { + let handles = self.handle_positions(document, value); + handles.get(self.handle_index).copied().or_else(|| handles.first().copied()) + } + /// Pure hover test: returns the mouse's distance to the handle when it is a hover candidate, or /// `None` otherwise. The manager uses this distance to resolve priority when several gizmos /// overlap (the closest handle wins). This performs no state mutation. @@ -132,15 +148,28 @@ impl GenericSliderGizmo { let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); - let handle = viewport.transform_point2(self.handle_position_local(value)); - // Hide the gizmo when the shape is too small on screen to interact with reliably. - if handle.distance(center) < GIZMO_HIDE_THRESHOLD { - return None; - } + self.handle_positions(document, value) + .into_iter() + .map(|local| viewport.transform_point2(local)) + // Hide the gizmo when the shape is too small on screen to interact with reliably. + .filter(|handle| handle.distance(center) >= GIZMO_HIDE_THRESHOLD) + .map(|handle| mouse_position.distance(handle)) + .filter(|distance| *distance <= SLIDER_HANDLE_HOVER_THRESHOLD) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + } + + /// Index of the grab point nearest the cursor, so a drag knows which ray it runs along. + fn nearest_handle_index(&self, document: &DocumentMessageHandler, value: f64, mouse_position: DVec2) -> usize { + let viewport = document.metadata().transform_to_viewport(self.layer); - let distance = mouse_position.distance(handle); - (distance <= SLIDER_HANDLE_HOVER_THRESHOLD).then_some(distance) + self.handle_positions(document, value) + .into_iter() + .map(|local| mouse_position.distance(viewport.transform_point2(local))) + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(index, _)| index) + .unwrap_or(0) } /// Transition into the hovered state (no-op if already hovered or dragging). Capturing the @@ -154,6 +183,7 @@ impl GenericSliderGizmo { self.state = GenericSliderState::Hover; self.initial_value = value; + self.handle_index = self.nearest_handle_index(document, value, mouse_position); self.snap_targets = match self.info.behavior.snap_targets { Some(targets) => targets(&self.context(document, mouse_position, None)), None => Vec::new(), @@ -176,7 +206,12 @@ impl GenericSliderGizmo { let viewport = document.metadata().transform_to_viewport(self.layer); let local_mouse = viewport.inverse().transform_point2(input.mouse.position); - let mut value = local_mouse.x; + // Project the cursor onto the ray through the grabbed handle. For the default single handle that ray + // is the +X axis and this is just `local_mouse.x`; for a handle sitting on the shape's own geometry + // it is the ray the user is visibly pulling along. + let Some(anchor) = self.active_handle_local(document, self.initial_value) else { return }; + let ray = anchor.try_normalize().unwrap_or(DVec2::X); + let mut value = local_mouse.dot(ray); // Preserve the sign of the original value for parameters (like radius) that can be negative. if self.initial_value.is_sign_negative() { @@ -223,6 +258,12 @@ impl GenericSliderGizmo { /// Draw the handle dot, plus a guide line from the layer origin while hovered or dragging. pub fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) { + // The shape's own overlay runs in every state: a resting affordance is exactly the case it wants to + // draw for, and the generic handle below only appears once the gizmo is engaged. + if let Some(overlay) = self.info.behavior.overlay { + overlay(&self.context(document, mouse_position, shape_editor), overlay_context); + } + if self.state == GenericSliderState::Inactive { return; } @@ -230,7 +271,8 @@ impl GenericSliderGizmo { let Some(value) = self.current_value(document) else { return }; let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); - let handle = viewport.transform_point2(self.handle_position_local(value)); + let Some(local) = self.active_handle_local(document, value) else { return }; + let handle = viewport.transform_point2(local); if handle.distance(center) < GIZMO_HIDE_THRESHOLD { return; @@ -238,10 +280,6 @@ impl GenericSliderGizmo { overlay_context.line(center, handle, None, None); overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None); - - if let Some(overlay) = self.info.behavior.overlay { - overlay(&self.context(document, mouse_position, shape_editor), overlay_context); - } } pub fn mouse_cursor_icon(&self) -> Option { diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 76783dd1a2..589a0e5305 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -14,7 +14,7 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoBehavior, GizmoContext, GizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::shapes::shape_utility::{ - extract_polygon_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, + draw_snapping_ticks, extract_polygon_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, }; use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; @@ -27,6 +27,7 @@ pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { snap_targets: None, overlay: Some(star_sides_overlay), coupled_writes: None, + handle_positions: None, }; /// Either of the star's radius handles: snaps to the radii where the star's points line up, and previews @@ -35,6 +36,7 @@ pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { snap_targets: Some(star_snap_radii), overlay: Some(star_radius_overlay), coupled_writes: None, + handle_positions: Some(star_radius_handles), }; /// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. @@ -42,6 +44,7 @@ pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { snap_targets: None, overlay: Some(polygon_sides_overlay), coupled_writes: None, + handle_positions: None, }; /// The radii at which dragging one of a star's radius handles makes its points line up: the value where @@ -98,13 +101,65 @@ fn star_snap_radii(context: &GizmoContext) -> Vec { snap_radii } -/// Show the star's outline whenever one of its radius handles is in play, so the user can see the whole -/// shape responding rather than just the handle they are holding. +/// A star's radius is grabbable at every vertex that radius controls: `radius_1` at the outer points, +/// `radius_2` at the inner ones. Whichever the user takes hold of, the drag runs out along that point. +fn star_radius_handles(context: &GizmoContext, value: f64) -> Vec { + let Some((sides, _, _)) = extract_star_parameters(Some(context.layer), context.document) else { + return Vec::new(); + }; + + (star_first_vertex(context)..2 * sides) + .step_by(2) + .map(|vertex| { + let angle = ((vertex as f64) * PI) / (sides as f64); + DVec2::new(value * angle.sin(), -value * angle.cos()) + }) + .collect() +} + +/// The vertex a star's radius parameter starts at: outer points are even, inner points odd. +fn star_first_vertex(context: &GizmoContext) -> u32 { + if context.parameter == ParameterRef::from(star::Radius2Input) { 1 } else { 0 } +} + +/// At rest, mark every vertex this radius controls so the handles are discoverable. Once one is engaged, +/// swap to the ray it is being pulled along, the outline of the shape being reshaped, and ticks at each +/// radius the drag will snap to. fn star_radius_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let Some((sides, radius_1, radius_2)) = extract_star_parameters(Some(context.layer), context.document) else { + return; + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let center = viewport.transform_point2(DVec2::ZERO); + let first_vertex = star_first_vertex(context); + if context.state == GizmoState::Inactive { + for vertex in (first_vertex..2 * sides).step_by(2) { + let point = star_vertex_position(viewport, vertex as i32, sides, radius_1, radius_2); + + // Once the star is this small on screen the handles crowd its center and cannot be told apart. + if point.distance(center) < GIZMO_HIDE_THRESHOLD { + return; + } + overlay_context.manipulator_handle(point, false, None); + } return; } + + let vertex = first_vertex as i32 + 2 * context.handle_index as i32; + let point = star_vertex_position(viewport, vertex, sides, radius_1, radius_2); + let Some(direction) = (point - center).try_normalize() else { return }; + + // Extend the ray across the viewport: the radius keeps growing past the edge of the shape, and the line + // is what makes the direction of the drag readable. + overlay_context.line(center, center + direction * overlay_context.viewport.size().into_dvec2().length(), None, None); star_outline(Some(context.layer), context.document, overlay_context); + + // The snap radii are only meaningful while both radii share a sign; see `star_snap_radii`. + if (radius_1.signum() * radius_2.signum()).is_sign_positive() { + let angle = ((vertex as f64) * PI) / (sides as f64); + draw_snapping_ticks(&star_snap_radii(context), direction, viewport, angle, overlay_context); + } } fn star_sides_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 3cb76d06d1..1e3ce58436 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -10,23 +10,21 @@ use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHand use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; -use crate::messages::tool::common_functionality::shapes::star_shape::StarGizmoHandler; use glam::DVec2; use std::collections::VecDeque; /// A unified enum wrapper around all available shape-specific gizmo handlers. /// -/// This abstraction allows `GizmoManager` to interact with different shape gizmos (like Star or Arc) +/// This abstraction allows `GizmoManager` to interact with different shape gizmos (like Arc or Grid) /// using a common interface without needing to know the specific shape type at compile time. /// -/// Each variant stores a concrete handler (e.g., `StarGizmoHandler`) that implements the shape-specific +/// Each variant stores a concrete handler (e.g., `ArcGizmoHandler`) that implements the shape-specific /// logic for rendering overlays, responding to input, and modifying shape parameters. Shapes whose -/// gizmos have been migrated to the registry-driven system (polygon, circle, heart) use the `Generic` variant. +/// gizmos have been migrated to the registry-driven system (polygon, circle, heart, star) use the `Generic` variant. #[derive(Clone, Debug, Default)] pub enum ShapeGizmoHandlers { #[default] None, - Star(StarGizmoHandler), Arc(ArcGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), @@ -40,7 +38,6 @@ impl ShapeGizmoHandlers { /// Used for grouping logic and distinguishing between handler types at runtime. pub fn kind(&self) -> &'static str { match self { - Self::Star(_) => "star", Self::Arc(_) => "arc", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", @@ -52,7 +49,6 @@ impl ShapeGizmoHandlers { /// Dispatches interaction state updates to the corresponding shape-specific handler. pub fn handle_state(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { match self { - Self::Star(h) => h.handle_state(layer, mouse_position, document, responses), Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), @@ -64,7 +60,6 @@ impl ShapeGizmoHandlers { /// Checks if any interactive part of the gizmo is currently hovered. pub fn is_any_gizmo_hovered(&self) -> bool { match self { - Self::Star(h) => h.is_any_gizmo_hovered(), Self::Arc(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), @@ -76,7 +71,6 @@ impl ShapeGizmoHandlers { /// Passes the click interaction to the appropriate gizmo handler if one is hovered. pub fn handle_click(&mut self) { match self { - Self::Star(h) => h.handle_click(), Self::Arc(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), @@ -88,7 +82,6 @@ impl ShapeGizmoHandlers { /// Updates the gizmo state while the user is dragging a handle (e.g., adjusting radius). pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { match self { - Self::Star(h) => h.handle_update(drag_start, document, input, responses), Self::Arc(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), @@ -100,7 +93,6 @@ impl ShapeGizmoHandlers { /// Cleans up any state used by the gizmo handler. pub fn cleanup(&mut self) { match self { - Self::Star(h) => h.cleanup(), Self::Arc(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), @@ -120,7 +112,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Star(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), @@ -139,7 +130,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Star(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), @@ -150,7 +140,6 @@ impl ShapeGizmoHandlers { pub fn gizmo_cursor_icon(&self) -> Option { match self { - Self::Star(h) => h.mouse_cursor_icon(), Self::Arc(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), @@ -183,8 +172,9 @@ impl GizmoManager { /// Returns `None` if the given layer does not represent a shape with a registered gizmo. pub fn detect_shape_handler(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Option { // Star + // Star — migrated to the generic, registry-driven gizmo system (sides dial, two radius handle sets). if graph_modification_utils::get_star_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Star(StarGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Polygon — migrated to the generic, registry-driven gizmo system (sides dial). if graph_modification_utils::get_polygon_id(layer, &document.network_interface).is_some() { diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index 22200b6afa..a54edd10a1 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -78,6 +78,8 @@ pub struct GizmoContext<'a> { /// The path editor's state, available while overlays are being drawn. A shape uses it to stand down /// when the cursor is over an editable segment, so a resting hint never competes with path editing. pub shape_editor: Option<&'a ShapeState>, + /// Which of the parameter's grab points is in play, indexing the list from `handle_positions`. + pub handle_index: usize, } /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. @@ -98,6 +100,13 @@ pub struct GizmoBehavior { /// Additional inputs to write alongside the dragged one, given the value the drag produced. Used by /// parameters that are only meaningful in combination. pub coupled_writes: Option Vec<(ParameterRef, TaggedValue)>>, + /// Where this parameter can be grabbed, in the layer's local space, given its current value. + /// + /// A length parameter is usually grabbable in exactly one place, which is the default: a handle sitting + /// `value` out along the local +X axis. Some shapes offer the same parameter in several places at once — + /// a star's outer radius can be taken hold of at any of its outer points — so this returns all of them. + /// The drag then runs along the ray through whichever one the user grabbed, not along a fixed axis. + pub handle_positions: Option Vec>, } impl GizmoBehavior { @@ -106,6 +115,7 @@ impl GizmoBehavior { snap_targets: None, overlay: None, coupled_writes: None, + handle_positions: None, }; } diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs index 585257fef5..1b3bfce112 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs @@ -1,6 +1,4 @@ pub mod circle_arc_radius_handle; pub mod grid_rows_columns_gizmo; -pub mod number_of_points_dial; -pub mod point_radius_handle; pub mod spiral_turns_handle; pub mod sweep_angle_gizmo; diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/number_of_points_dial.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/number_of_points_dial.rs deleted file mode 100644 index 1badf26484..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/number_of_points_dial.rs +++ /dev/null @@ -1,216 +0,0 @@ -use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; -use crate::messages::frontend::utility_types::MouseCursorIcon; -use crate::messages::message::Message; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::prelude::Responses; -use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage}; -use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{extract_polygon_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline}; -use crate::messages::tool::common_functionality::shapes::shape_utility::{extract_star_parameters, star_vertex_position}; -use glam::{DAffine2, DVec2}; -use graph_craft::document::NodeInput; -use graph_craft::document::value::TaggedValue; -use graphene_std::ParameterRef; -use std::collections::VecDeque; -use std::f64::consts::TAU; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum NumberOfPointsDialState { - #[default] - Inactive, - Hover, - Dragging, -} - -#[derive(Clone, Debug, Default)] -pub struct NumberOfPointsDial { - pub layer: Option, - pub initial_points: u32, - pub handle_state: NumberOfPointsDialState, -} - -impl NumberOfPointsDial { - pub fn cleanup(&mut self) { - self.handle_state = NumberOfPointsDialState::Inactive; - self.layer = None; - } - - pub fn update_state(&mut self, state: NumberOfPointsDialState) { - self.handle_state = state; - } - - pub fn is_hovering(&self) -> bool { - self.handle_state == NumberOfPointsDialState::Hover - } - - pub fn is_dragging(&self) -> bool { - self.handle_state == NumberOfPointsDialState::Dragging - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - match &self.handle_state { - NumberOfPointsDialState::Inactive => { - // Star - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - let point_on_max_radius = star_vertex_position(viewport, 0, sides, radius1, radius2); - - if mouse_position.distance(center) < NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH && point_on_max_radius.distance(center) > GIZMO_HIDE_THRESHOLD { - self.layer = Some(layer); - self.initial_points = sides; - self.update_state(NumberOfPointsDialState::Hover); - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); - } - } - - // Polygon - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - let point_on_max_radius = polygon_vertex_position(viewport, 0, sides, radius); - - if mouse_position.distance(center) < NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH && point_on_max_radius.distance(center) > GIZMO_HIDE_THRESHOLD { - self.layer = Some(layer); - self.initial_points = sides; - self.update_state(NumberOfPointsDialState::Hover); - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); - } - } - } - NumberOfPointsDialState::Hover | NumberOfPointsDialState::Dragging => { - let Some(layer) = self.layer else { return }; - - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - if mouse_position.distance(center) > NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH && matches!(&self.handle_state, NumberOfPointsDialState::Hover) { - self.update_state(NumberOfPointsDialState::Inactive); - self.layer = None; - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); - } - } - } - } - - pub fn overlays(&self, document: &DocumentMessageHandler, layer: Option, shape_editor: &mut &mut ShapeState, mouse_position: DVec2, overlay_context: &mut OverlayContext) { - match &self.handle_state { - NumberOfPointsDialState::Inactive => { - let Some(layer) = layer else { return }; - - // Star - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let radius = radius1.max(radius2); - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - if let Some(closest_segment) = shape_editor.upper_closest_segment(&document.network_interface, mouse_position, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD) - && closest_segment.layer() == layer - { - return; - } - let point_on_max_radius = star_vertex_position(viewport, 0, sides, radius1, radius2); - - if inside_star(viewport, sides, radius1, radius2, mouse_position) && point_on_max_radius.distance(center) > GIZMO_HIDE_THRESHOLD { - self.draw_spokes(center, viewport, sides, radius, overlay_context); - return; - } - } - - // Polygon - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - if let Some(closest_segment) = shape_editor.upper_closest_segment(&document.network_interface, mouse_position, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD) - && closest_segment.layer() == layer - { - return; - } - let point_on_max_radius = polygon_vertex_position(viewport, 0, sides, radius); - - if inside_polygon(viewport, sides, radius, mouse_position) && point_on_max_radius.distance(center) > GIZMO_HIDE_THRESHOLD { - self.draw_spokes(center, viewport, sides, radius, overlay_context); - } - } - } - NumberOfPointsDialState::Hover | NumberOfPointsDialState::Dragging => { - let Some(layer) = self.layer else { - return; - }; - - // Get the star's greater radius or polygon's radius, as well as the number of sides - let Some((sides, radius)) = extract_star_parameters(Some(layer), document) - .map(|(sides, r1, r2)| (sides, r1.max(r2))) - .or_else(|| extract_polygon_parameters(Some(layer), document)) - else { - return; - }; - - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - // Draw either the star or polygon outline - star_outline(Some(layer), document, overlay_context); - polygon_outline(Some(layer), document, overlay_context); - - self.draw_spokes(center, viewport, sides, radius, overlay_context); - } - } - } - - fn draw_spokes(&self, center: DVec2, viewport: DAffine2, sides: u32, radius: f64, overlay_context: &mut OverlayContext) { - for i in 0..sides { - let angle = ((i as f64) * TAU) / (sides as f64); - - let point = viewport.transform_point2(DVec2 { - x: radius * angle.sin(), - y: -radius * angle.cos(), - }); - - let Some(direction) = (point - center).try_normalize() else { continue }; - - // If the user zooms out such that shape is very small hide the gizmo - if point.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - let end_point = direction * NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH; - if matches!(self.handle_state, NumberOfPointsDialState::Hover | NumberOfPointsDialState::Dragging) { - overlay_context.line(center, end_point * NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION + center, None, None); - } else { - overlay_context.line(center, end_point + center, None, None); - } - } - } - - pub fn update_number_of_sides(&self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, drag_start: DVec2) { - let delta = input.mouse.position - drag_start; - let sign = (input.mouse.position.x - drag_start.x).signum(); - let net_delta = (delta.length() / 25.).round() * sign; - - let Some(layer) = self.layer else { return }; - - // This dial serves both Star and Polygon layers, so resolve which node is present to know whose sides parameter to write - let (node_id, sides_parameter) = if let Some(node_id) = graph_modification_utils::get_star_id(layer, &document.network_interface) { - (node_id, ParameterRef::from(graphene_std::vector::generator_nodes::star::SidesInput)) - } else if let Some(node_id) = graph_modification_utils::get_polygon_id(layer, &document.network_interface) { - (node_id, ParameterRef::from(graphene_std::vector::generator_nodes::regular_polygon::SidesInput)) - } else { - return; - }; - - let new_point_count = ((self.initial_points as i32) + (net_delta as i32)).max(3); - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, sides_parameter), - input: NodeInput::value(TaggedValue::U32(new_point_count as u32), false), - }); - responses.add(NodeGraphMessage::RunDocumentGraph); - } -} diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/point_radius_handle.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/point_radius_handle.rs deleted file mode 100644 index 23b399f1b9..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/point_radius_handle.rs +++ /dev/null @@ -1,478 +0,0 @@ -use crate::consts::GIZMO_HIDE_THRESHOLD; -use crate::consts::{COLOR_OVERLAY_RED, POINT_RADIUS_HANDLE_SNAP_THRESHOLD}; -use crate::messages::frontend::utility_types::MouseCursorIcon; -use crate::messages::message::Message; -use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::{overlays::utility_types::OverlayContext, utility_types::network_interface::InputConnector}; -use crate::messages::prelude::FrontendMessage; -use crate::messages::prelude::Responses; -use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler, NodeGraphMessage}; -use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; -use crate::messages::tool::common_functionality::shapes::shape_utility::{draw_snapping_ticks, extract_polygon_parameters, polygon_outline, polygon_vertex_position, star_outline}; -use crate::messages::tool::common_functionality::shapes::shape_utility::{extract_star_parameters, star_vertex_position}; -use glam::DVec2; -use graph_craft::document::NodeInput; -use graph_craft::document::value::TaggedValue; -use graphene_std::ParameterRef; -use graphene_std::vector::generator_nodes::{regular_polygon, star}; -use std::collections::VecDeque; -use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_4, PI, SQRT_2}; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum PointRadiusHandleState { - #[default] - Inactive, - Hover, - Dragging, - Snapped(usize), -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct PointRadiusHandle { - pub layer: Option, - point: u32, - /// The radius parameter the hovered or dragged handle writes to: a star's first or second radius, or a polygon's radius. - radius_parameter: Option, - snap_radii: Vec, - initial_radius: f64, - handle_state: PointRadiusHandleState, -} - -impl PointRadiusHandle { - pub fn cleanup(&mut self) { - self.handle_state = PointRadiusHandleState::Inactive; - self.snap_radii.clear(); - self.layer = None; - } - - pub fn hovered(&self) -> bool { - self.handle_state == PointRadiusHandleState::Hover - } - - pub fn is_dragging_or_snapped(&self) -> bool { - self.handle_state == PointRadiusHandleState::Dragging || matches!(self.handle_state, PointRadiusHandleState::Snapped(_)) - } - - pub fn update_state(&mut self, state: PointRadiusHandleState) { - self.handle_state = state; - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, document: &DocumentMessageHandler, mouse_position: DVec2, responses: &mut VecDeque) { - match &self.handle_state { - PointRadiusHandleState::Inactive => { - // Draw the point handle gizmo for the star shape - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - - for i in 0..2 * sides { - let (radius, radius_parameter) = if i % 2 == 0 { - (radius1, ParameterRef::from(star::Radius1Input)) - } else { - (radius2, ParameterRef::from(star::Radius2Input)) - }; - let point = star_vertex_position(viewport, i as i32, sides, radius1, radius2); - let center = viewport.transform_point2(DVec2::ZERO); - - // If the user zooms out such that shape is very small hide the gizmo - if point.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - if point.distance(mouse_position) < 5. { - self.snap_radii = Self::calculate_snap_radii(document, layer, &radius_parameter); - self.radius_parameter = Some(radius_parameter); - self.layer = Some(layer); - self.point = i; - self.initial_radius = radius; - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); - self.update_state(PointRadiusHandleState::Hover); - - return; - } - } - } - - // Draw the point handle gizmo for the polygon shape - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - - for i in 0..sides { - let point = polygon_vertex_position(viewport, i as i32, sides, radius); - let center = viewport.transform_point2(DVec2::ZERO); - - // If the user zooms out such that shape is very small hide the gizmo - if point.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - if point.distance(mouse_position) < 5. { - self.radius_parameter = Some(regular_polygon::RadiusInput.into()); - self.layer = Some(layer); - self.point = i; - self.snap_radii.clear(); - self.initial_radius = radius; - self.update_state(PointRadiusHandleState::Hover); - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); - return; - } - } - } - } - - PointRadiusHandleState::Dragging | PointRadiusHandleState::Hover => { - let Some(layer) = self.layer else { return }; - - let viewport = document.metadata().transform_to_viewport(layer); - - // Star - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let point = star_vertex_position(viewport, self.point as i32, sides, radius1, radius2); - - if matches!(&self.handle_state, PointRadiusHandleState::Hover) && (mouse_position - point).length() > 5. { - self.update_state(PointRadiusHandleState::Inactive); - self.layer = None; - return; - } - } - - // Polygon - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let point = polygon_vertex_position(viewport, self.point as i32, sides, radius); - - if matches!(&self.handle_state, PointRadiusHandleState::Hover) && (mouse_position - point).length() > 5. { - self.update_state(PointRadiusHandleState::Inactive); - self.layer = None; - } - } - } - PointRadiusHandleState::Snapped(_) => {} - } - } - - pub fn overlays(&self, selected_star_layer: Option, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { - match &self.handle_state { - PointRadiusHandleState::Inactive => { - let Some(layer) = selected_star_layer else { return }; - - // Draw the point handle gizmo for the star shape - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - - for i in 0..(2 * sides) { - let point = star_vertex_position(viewport, i as i32, sides, radius1, radius2); - let center = viewport.transform_point2(DVec2::ZERO); - - // If the user zooms out such that shape is very small hide the gizmo - if point.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - overlay_context.manipulator_handle(point, false, None); - } - } - - // Draw the point handle gizmo for the Polygon shape - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let viewport = document.metadata().transform_to_viewport(layer); - - for i in 0..sides { - let point = polygon_vertex_position(viewport, i as i32, sides, radius); - let center = viewport.transform_point2(DVec2::ZERO); - - // If the user zooms out such that shape is very small hide the gizmo - if point.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - overlay_context.manipulator_handle(point, false, None); - } - } - } - - PointRadiusHandleState::Dragging | PointRadiusHandleState::Hover => { - let Some(layer) = self.layer else { return }; - - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - let viewport_diagonal = overlay_context.viewport.size().into_dvec2().length(); - - // Star - if let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) { - let angle = ((self.point as f64) * PI) / (sides as f64); - let point = star_vertex_position(viewport, self.point as i32, sides, radius1, radius2); - - let Some(direction) = (point - center).try_normalize() else { return }; - - // Draws the ray from the center to the dragging point extending till the viewport - overlay_context.manipulator_handle(point, true, None); - overlay_context.line(center, center + direction * viewport_diagonal, None, None); - star_outline(Some(layer), document, overlay_context); - - // Make the ticks for snapping - if (radius1.signum() * radius2.signum()).is_sign_positive() { - draw_snapping_ticks(&self.snap_radii, direction, viewport, angle, overlay_context); - } - - return; - } - - // Polygon - if let Some((sides, radius)) = extract_polygon_parameters(Some(layer), document) { - let point = polygon_vertex_position(viewport, self.point as i32, sides, radius); - - let Some(direction) = (point - center).try_normalize() else { return }; - - // Draws the ray from the center to the dragging point extending till the viewport - overlay_context.manipulator_handle(point, true, None); - overlay_context.line(center, center + direction * viewport_diagonal, None, None); - - polygon_outline(Some(layer), document, overlay_context); - } - } - PointRadiusHandleState::Snapped(snapping_index) => { - let Some(layer) = self.layer else { return }; - let Some((sides, radius1, radius2)) = extract_star_parameters(Some(layer), document) else { - return; - }; - - let viewport = document.metadata().transform_to_viewport(layer); - - match snapping_index { - // Make a triangle with previous two points - 0 => { - let before_outer_position = star_vertex_position(viewport, (self.point as i32) - 2, sides, radius1, radius2); - let outer_position = star_vertex_position(viewport, (self.point as i32) - 1, sides, radius1, radius2); - let point_position = star_vertex_position(viewport, self.point as i32, sides, radius1, radius2); - - overlay_context.line(before_outer_position, outer_position, Some(COLOR_OVERLAY_RED), Some(3.)); - overlay_context.line(outer_position, point_position, Some(COLOR_OVERLAY_RED), Some(3.)); - - let before_outer_position = viewport.inverse().transform_point2(before_outer_position); - let outer_position = viewport.inverse().transform_point2(outer_position); - let point_position = viewport.inverse().transform_point2(point_position); - - let l1 = (before_outer_position - outer_position).length() * 0.2; - let Some(l1_direction) = (before_outer_position - outer_position).try_normalize() else { return }; - let Some(l2_direction) = (point_position - outer_position).try_normalize() else { return }; - let Some(direction) = (-outer_position).try_normalize() else { return }; - - let new_point = SQRT_2 * l1 * direction + outer_position; - - let before_outer_position = l1 * l1_direction + outer_position; - let point_position = l1 * l2_direction + outer_position; - - overlay_context.line( - viewport.transform_point2(before_outer_position), - viewport.transform_point2(new_point), - Some(COLOR_OVERLAY_RED), - Some(3.), - ); - overlay_context.line(viewport.transform_point2(new_point), viewport.transform_point2(point_position), Some(COLOR_OVERLAY_RED), Some(3.)); - } - 1 => { - let before_outer_position = star_vertex_position(viewport, (self.point as i32) - 1, sides, radius1, radius2); - let after_point_position = star_vertex_position(viewport, (self.point as i32) + 1, sides, radius1, radius2); - let point_position = star_vertex_position(viewport, self.point as i32, sides, radius1, radius2); - - overlay_context.line(before_outer_position, point_position, Some(COLOR_OVERLAY_RED), Some(3.)); - overlay_context.line(point_position, after_point_position, Some(COLOR_OVERLAY_RED), Some(3.)); - - let before_outer_position = viewport.inverse().transform_point2(before_outer_position); - let after_point_position = viewport.inverse().transform_point2(after_point_position); - let point_position = viewport.inverse().transform_point2(point_position); - - let l1 = (before_outer_position - point_position).length() * 0.2; - let Some(l1_direction) = (before_outer_position - point_position).try_normalize() else { return }; - let Some(l2_direction) = (after_point_position - point_position).try_normalize() else { return }; - let Some(direction) = (-point_position).try_normalize() else { return }; - - let new_point = SQRT_2 * l1 * direction + point_position; - - let before_outer_position = l1 * l1_direction + point_position; - let after_point_position = l1 * l2_direction + point_position; - - overlay_context.line( - viewport.transform_point2(before_outer_position), - viewport.transform_point2(new_point), - Some(COLOR_OVERLAY_RED), - Some(3.), - ); - overlay_context.line(viewport.transform_point2(new_point), viewport.transform_point2(after_point_position), Some(COLOR_OVERLAY_RED), Some(3.)); - } - i => { - // Use `self.point` as absolute reference as it matches the index of vertices of the star starting from 0 - if i % 2 != 0 { - // Flipped case - let point_position = star_vertex_position(viewport, self.point as i32, sides, radius1, radius2); - let target_index = (1 - (*i as i32)).abs() + (self.point as i32); - let target_point_position = star_vertex_position(viewport, target_index, sides, radius1, radius2); - - let mirrored_index = 2 * (self.point as i32) - target_index; - let mirrored = star_vertex_position(viewport, mirrored_index, sides, radius1, radius2); - - overlay_context.line(point_position, target_point_position, Some(COLOR_OVERLAY_RED), Some(3.)); - overlay_context.line(point_position, mirrored, Some(COLOR_OVERLAY_RED), Some(3.)); - } else { - let outer_index = (self.point as i32) - 1; - let outer_position = star_vertex_position(viewport, outer_index, sides, radius1, radius2); - - // The vertex which is colinear with the point we are dragging and its previous outer vertex - let target_index = (self.point as i32) + (*i as i32) - 1; - let target_point_position = star_vertex_position(viewport, target_index, sides, radius1, radius2); - - let mirrored_index = 2 * outer_index - target_index; - - let mirrored = star_vertex_position(viewport, mirrored_index, sides, radius1, radius2); - - overlay_context.line(outer_position, target_point_position, Some(COLOR_OVERLAY_RED), Some(3.)); - overlay_context.line(outer_position, mirrored, Some(COLOR_OVERLAY_RED), Some(3.)); - } - } - } - - star_outline(Some(layer), document, overlay_context); - } - } - } - - fn calculate_snap_radii(document: &DocumentMessageHandler, layer: LayerNodeIdentifier, radius_parameter: &ParameterRef) -> Vec { - let mut snap_radii = Vec::new(); - - let Some(parameters) = NodeGraphLayer::new(layer, &document.network_interface).find_node_parameters(star::IDENTIFIER) else { - return snap_radii; - }; - - let (Some(&TaggedValue::F64(radius_1)), Some(&TaggedValue::F64(radius_2))) = (parameters.value(star::Radius1Input), parameters.value(star::Radius2Input)) else { - return snap_radii; - }; - - let other_radius = if *radius_parameter == ParameterRef::from(star::Radius2Input) { radius_1 } else { radius_2 }; - - let Some(&TaggedValue::U32(sides)) = parameters.value(star::SidesInput) else { - return snap_radii; - }; - - let both_radii_negative = radius_1.is_sign_negative() && radius_2.is_sign_negative(); - let both_radii_same_sign = (radius_1.signum() * radius_2.signum()).is_sign_positive(); - - // When only one of the radii is negative, no need for snapping - if !both_radii_same_sign { - return snap_radii; - } - - let sign = if both_radii_negative { -1. } else { 1. }; - - // Inner radius for 90° - let b = FRAC_PI_4 * 3. - PI / (sides as f64); - let angle = b.sin(); - let required_radius = (other_radius.abs() * sign / angle) * FRAC_1_SQRT_2; - - snap_radii.push(required_radius); - - // Also push the case when the when it length increases more than the other - - let flipped = other_radius.abs() * sign * angle * SQRT_2; - - snap_radii.push(flipped); - - for i in 1..sides { - let sides = sides as f64; - let i = i as f64; - let denominator = 2. * ((PI * (i - 1.)) / sides).cos() * ((PI * i) / sides).sin(); - let numerator = ((2. * PI * i) / sides).sin(); - let factor = numerator / denominator; - - if factor < 0. { - break; - } - - if other_radius.abs() * factor > 1e-6 { - snap_radii.push(other_radius.abs() * sign * factor); - } - - snap_radii.push((other_radius.abs() * sign) / factor); - } - - snap_radii - } - - fn check_snapping(&self, new_radius: f64, original_radius: f64) -> Option<(usize, f64)> { - self.snap_radii - .iter() - .enumerate() - .filter(|(_, rad)| (**rad - new_radius).abs() < POINT_RADIUS_HANDLE_SNAP_THRESHOLD) - .min_by(|(i_a, a), (i_b, b)| { - let dist_a = (**a - new_radius).abs(); - let dist_b = (**b - new_radius).abs(); - - // Check if either index is 0 or 1 and prioritize them - match (*i_a == 0 || *i_a == 1, *i_b == 0 || *i_b == 1) { - // `a` is priority index, `b` is not - (true, false) => std::cmp::Ordering::Less, - // `b` is priority index, `a` is not - (false, true) => std::cmp::Ordering::Greater, - // Normal comparison - _ => dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal), - } - }) - .map(|(i, rad)| (i, *rad - original_radius)) - } - - pub fn update_inner_radius(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, drag_start: DVec2) { - let Some(layer) = self.layer else { return }; - let Some(radius_parameter) = self.radius_parameter.clone() else { return }; - - // The stored parameter names the node it belongs to (Star or Polygon), so locate that same node on the layer - let Some(node_id) = NodeGraphLayer::new(layer, &document.network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(radius_parameter.node_identifier.clone())) else { - return; - }; - - let viewport_transform = document.network_interface.document_metadata().transform_to_viewport(layer); - let center = viewport_transform.transform_point2(DVec2::ZERO); - - let original_radius = self.initial_radius; - - let delta = viewport_transform.inverse().transform_point2(input.mouse.position) - viewport_transform.inverse().transform_point2(drag_start); - let radius = drag_start - center; - let projection = delta.project_onto(radius); - let sign = radius.dot(delta).signum(); - - let mut net_delta = projection.length() * sign * original_radius.signum(); - let new_radius = original_radius + net_delta; - - self.update_state(PointRadiusHandleState::Dragging); - - self.check_if_radius_flipped(original_radius, new_radius, document, layer, &radius_parameter); - - if let Some((index, snapped_delta)) = self.check_snapping(new_radius, original_radius) { - net_delta = snapped_delta; - self.update_state(PointRadiusHandleState::Snapped(index)); - } - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, radius_parameter), - input: NodeInput::value(TaggedValue::F64(original_radius + net_delta), false), - }); - responses.add(NodeGraphMessage::RunDocumentGraph); - } - - fn check_if_radius_flipped(&mut self, original_radius: f64, new_radius: f64, document: &DocumentMessageHandler, layer: LayerNodeIdentifier, radius_parameter: &ParameterRef) { - let Some(parameters) = NodeGraphLayer::new(layer, &document.network_interface).find_node_parameters(star::IDENTIFIER) else { - return; - }; - - let (Some(&TaggedValue::F64(radius_1)), Some(&TaggedValue::F64(radius_2))) = (parameters.value(star::Radius1Input), parameters.value(star::Radius2Input)) else { - return; - }; - - let other_radius = if *radius_parameter == ParameterRef::from(star::Radius2Input) { radius_1 } else { radius_2 }; - - let flipped = (other_radius.is_sign_positive() && original_radius.is_sign_negative() && new_radius.is_sign_positive()) - || (other_radius.is_sign_negative() && original_radius.is_sign_positive() && new_radius.is_sign_negative()); - - if flipped { - self.snap_radii = Self::calculate_snap_radii(document, layer, radius_parameter); - } - } -} diff --git a/editor/src/messages/tool/common_functionality/shapes/star_shape.rs b/editor/src/messages/tool/common_functionality/shapes/star_shape.rs index 049f8b27c8..8d0e7f69aa 100644 --- a/editor/src/messages/tool/common_functionality/shapes/star_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/star_shape.rs @@ -1,107 +1,15 @@ use super::shape_utility::{ShapeToolModifierKey, update_radius_sign}; use super::*; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type}; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::number_of_points_dial::{NumberOfPointsDial, NumberOfPointsDialState}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::point_radius_handle::{PointRadiusHandle, PointRadiusHandleState}; use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, star_outline}; use crate::messages::tool::tool_messages::tool_prelude::*; use core::f64; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use std::collections::VecDeque; -#[derive(Clone, Debug, Default)] -pub struct StarGizmoHandler { - number_of_points_dial: NumberOfPointsDial, - point_radius_handle: PointRadiusHandle, -} - -impl ShapeGizmoHandler for StarGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - self.number_of_points_dial.is_hovering() || self.point_radius_handle.hovered() - } - - fn handle_state(&mut self, selected_star_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - self.number_of_points_dial.handle_actions(selected_star_layer, mouse_position, document, responses); - self.point_radius_handle.handle_actions(selected_star_layer, document, mouse_position, responses); - } - - fn handle_click(&mut self) { - if self.number_of_points_dial.is_hovering() { - self.number_of_points_dial.update_state(NumberOfPointsDialState::Dragging); - return; - } - - if self.point_radius_handle.hovered() { - self.point_radius_handle.update_state(PointRadiusHandleState::Dragging); - } - } - - fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.number_of_points_dial.is_dragging() { - self.number_of_points_dial.update_number_of_sides(document, input, responses, drag_start); - } - - if self.point_radius_handle.is_dragging_or_snapped() { - self.point_radius_handle.update_inner_radius(document, input, responses, drag_start); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - selected_star_layer: Option, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - self.number_of_points_dial.overlays(document, selected_star_layer, shape_editor, mouse_position, overlay_context); - self.point_radius_handle.overlays(selected_star_layer, document, overlay_context); - - star_outline(selected_star_layer, document, overlay_context); - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - if self.number_of_points_dial.is_dragging() { - self.number_of_points_dial.overlays(document, None, shape_editor, mouse_position, overlay_context); - } - - if self.point_radius_handle.is_dragging_or_snapped() { - self.point_radius_handle.overlays(None, document, overlay_context); - } - } - - fn cleanup(&mut self) { - self.number_of_points_dial.cleanup(); - self.point_radius_handle.cleanup(); - } - - fn mouse_cursor_icon(&self) -> Option { - if self.number_of_points_dial.is_dragging() || self.number_of_points_dial.is_hovering() { - return Some(MouseCursorIcon::EWResize); - } - - if self.point_radius_handle.is_dragging_or_snapped() || self.point_radius_handle.hovered() { - return Some(MouseCursorIcon::Default); - } - - None - } -} - #[derive(Default)] pub struct Star; From a34022d05e04f167d2f7a563a3e43a577b4a5b0e Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:06:32 +0530 Subject: [PATCH 13/24] Migrate the Spiral gizmo to the generic registry system A spiral's control neither extends nor steps: it winds. Dragging an endpoint sweeps around the origin, and the sweep has to keep counting past a full turn, which the angle between two points cannot express. Turns alone would also change the spiral's tightness as it grows, so the outer radius has to move with it. So the generic layer grows two things. `drag` lets a shape convert cursor motion into node inputs itself, returning every input the motion implies rather than the single value the default projection produces. And the slider now accumulates the angle swept about the layer's origin frame by frame, since that is bookkeeping no stateless hook can do for itself -- with a half-degree deadzone, because near the origin the angle between successive cursor positions is mostly noise. Because a multi-parameter drag is the only thing that knows how its parameters constrain each other, supplying `drag` also bypasses clamping and snapping. The registry entry drops to the turns control alone. The inner and outer radius sliders it used to declare were never reachable, and a handle for either would land at an arbitrary point on a curve that is nowhere near circular -- while winding the spiral from its own endpoints reads immediately. Both are still adjustable from the Properties panel. Deletes `SpiralTurns` and `SpiralGizmoHandler`. --- .../generic_gizmos/generic_slider_gizmo.rs | 77 +++++- .../gizmos/generic_gizmos/mod.rs | 6 +- .../gizmos/gizmo_behaviors.rs | 111 ++++++++- .../gizmos/gizmo_manager.rs | 14 +- .../gizmos/gizmo_registry.rs | 69 +++--- .../gizmos/shape_gizmos/mod.rs | 1 - .../shape_gizmos/spiral_turns_handle.rs | 226 ------------------ .../shapes/spiral_shape.rs | 69 +----- 8 files changed, 230 insertions(+), 343 deletions(-) delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/spiral_turns_handle.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 9aa3c35f9b..5b2a3c7fc2 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -8,12 +8,14 @@ use crate::consts::{GIZMO_HIDE_THRESHOLD, POINT_RADIUS_HANDLE_SNAP_THRESHOLD}; use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::message::Message; +use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, GizmoState, PositionHint}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, GizmoContext, GizmoInfo, GizmoState, PositionHint}; +use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::shape_editor::ShapeState; use glam::DVec2; use graph_craft::ProtoNodeIdentifier; @@ -25,6 +27,8 @@ use std::collections::VecDeque; /// Pixel radius within which the mouse is considered to be hovering the handle. const SLIDER_HANDLE_HOVER_THRESHOLD: f64 = 8.; +/// Per-frame rotation below which the swept angle is treated as cursor noise rather than intent. +const ANGLE_ACCUMULATION_DEADZONE: f64 = 0.5; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum GenericSliderState { @@ -51,6 +55,13 @@ pub struct GenericSliderGizmo { /// Which grab point the user took hold of, indexing `handle_positions`. Zero for the common case of a /// parameter with a single handle. handle_index: usize, + /// The node's inputs as they stood when the drag began. A drag that writes several parameters needs + /// them, since the live values are the ones it already wrote. + initial_parameters: Vec>, + /// Cursor position last frame, for accumulating swept angle. + previous_mouse_position: DVec2, + /// Angle swept around the layer origin since the drag began, in degrees. + total_angle: f64, } impl GenericSliderGizmo { @@ -64,6 +75,9 @@ impl GenericSliderGizmo { initial_value: 0., snap_targets: Vec::new(), handle_index: 0, + initial_parameters: Vec::new(), + previous_mouse_position: DVec2::ZERO, + total_angle: 0., } } @@ -78,6 +92,8 @@ impl GenericSliderGizmo { pub fn cleanup(&mut self) { self.state = GenericSliderState::Inactive; self.snap_targets.clear(); + self.initial_parameters.clear(); + self.total_angle = 0.; } /// Begin a drag if currently hovered. @@ -97,6 +113,14 @@ impl GenericSliderGizmo { } } + /// Snapshot every input of this gizmo's node, indexed the way its parameter symbols are. + fn read_all_parameters(&self, document: &DocumentMessageHandler) -> Vec> { + NodeGraphLayer::new(self.layer, &document.network_interface) + .find_node_inputs(&DefinitionIdentifier::ProtoNode(self.identifier.clone())) + .map(|inputs| inputs.iter().map(|input| input.as_value().cloned()).collect()) + .unwrap_or_default() + } + fn context<'a>(&self, document: &'a DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&'a ShapeState>) -> GizmoContext<'a> { GizmoContext { layer: self.layer, @@ -184,6 +208,9 @@ impl GenericSliderGizmo { self.state = GenericSliderState::Hover; self.initial_value = value; self.handle_index = self.nearest_handle_index(document, value, mouse_position); + self.initial_parameters = self.read_all_parameters(document); + self.previous_mouse_position = mouse_position; + self.total_angle = 0.; self.snap_targets = match self.info.behavior.snap_targets { Some(targets) => targets(&self.context(document, mouse_position, None)), None => Vec::new(), @@ -202,7 +229,32 @@ impl GenericSliderGizmo { /// Update the parameter live while dragging. The new value is the mouse's position projected /// onto the local +X axis, clamped to the registry's min/max bounds. - pub fn handle_update(&self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + self.accumulate_angle(document, input.mouse.position); + + if let Some(drag) = self.info.behavior.drag { + let drag_input = DragInput { + drag_start, + mouse_position: input.mouse.position, + initial_value: self.initial_value, + initial_parameters: self.initial_parameters.clone(), + total_angle: self.total_angle, + }; + + let writes = drag(&self.context(document, input.mouse.position, None), &drag_input); + if writes.is_empty() { + return; + } + for (parameter, value) in writes { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(self.node_id, parameter), + input: NodeInput::value(value, false), + }); + } + responses.add(NodeGraphMessage::RunDocumentGraph); + return; + } + let viewport = document.metadata().transform_to_viewport(self.layer); let local_mouse = viewport.inverse().transform_point2(input.mouse.position); @@ -239,6 +291,27 @@ impl GenericSliderGizmo { responses.add(NodeGraphMessage::RunDocumentGraph); } + /// Add this frame's rotation about the layer's origin to the running total, so a drag that winds several + /// times around keeps counting instead of wrapping at half a turn. + /// + /// Rotations smaller than half a degree are dropped: near the origin the angle between successive cursor + /// positions is dominated by noise, and feeding that in makes the value jitter while the cursor is still. + fn accumulate_angle(&mut self, document: &DocumentMessageHandler, mouse_position: DVec2) { + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + let inverse = viewport.inverse(); + + let delta = inverse + .transform_vector2(mouse_position - center) + .angle_to(inverse.transform_vector2(self.previous_mouse_position - center)) + .to_degrees(); + + self.previous_mouse_position = mouse_position; + if delta.is_finite() && delta.abs() >= ANGLE_ACCUMULATION_DEADZONE { + self.total_angle += delta; + } + } + /// Pull the value onto the nearest snap target within the threshold. Returns the value unchanged when /// nothing is in range. fn snap(&self, value: f64) -> f64 { diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs index 4acd128812..fccf4950f2 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -102,9 +102,9 @@ impl GenericGizmo { } } - fn handle_update(&self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { match self { - Self::Slider(g) => g.handle_update(document, input, responses), + Self::Slider(g) => g.handle_update(drag_start, document, input, responses), Self::Dial(g) => g.handle_update(drag_start, document, input, responses), } } @@ -216,7 +216,7 @@ impl ShapeGizmoHandler for GenericGizmoManager { } fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - for gizmo in &self.gizmos { + for gizmo in &mut self.gizmos { if gizmo.is_dragging() { gizmo.handle_update(drag_start, document, input, responses); } diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 589a0e5305..85adac8bcc 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -9,17 +9,23 @@ //! count and its *other* radius, and no amount of registry data expresses that. Those live here as plain //! functions, referenced from the [registry](super::gizmo_registry) table. +use crate::consts::COLOR_OVERLAY_RED; use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoBehavior, GizmoContext, GizmoState}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, GizmoBehavior, GizmoContext, GizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::shapes::shape_utility::{ - draw_snapping_ticks, extract_polygon_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, + draw_snapping_ticks, extract_polygon_parameters, extract_spiral_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, + star_vertex_position, }; +use crate::messages::tool::common_functionality::shapes::spiral_shape::calculate_spiral_endpoints; use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; +use graphene_std::NodeParameter; use graphene_std::ParameterRef; +use graphene_std::vector::algorithms::shapes::{calculate_growth_factor, spiral_point}; use graphene_std::vector::generator_nodes::star; +use graphene_std::vector::misc::SpiralType; use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_4, PI, SQRT_2, TAU}; /// The star's sides dial: previews the shape it is about to change. @@ -28,6 +34,7 @@ pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { overlay: Some(star_sides_overlay), coupled_writes: None, handle_positions: None, + drag: None, }; /// Either of the star's radius handles: snaps to the radii where the star's points line up, and previews @@ -37,6 +44,16 @@ pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { overlay: Some(star_radius_overlay), coupled_writes: None, handle_positions: Some(star_radius_handles), + drag: None, +}; + +/// The spiral's winding control. Grabbable at either end of the curve; dragging winds or unwinds it. +pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(spiral_turns_overlay), + coupled_writes: None, + handle_positions: Some(spiral_turns_handles), + drag: Some(spiral_turns_drag), }; /// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. @@ -45,6 +62,7 @@ pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { overlay: Some(polygon_sides_overlay), coupled_writes: None, handle_positions: None, + drag: None, }; /// The radii at which dragging one of a star's radius handles makes its points line up: the value where @@ -243,3 +261,92 @@ fn draw_spokes(viewport: DAffine2, sides: u32, radius: f64, state: GizmoState, o overlay_context.line(center, center + direction * length, None, None); } } + +/// Read one of the spiral's inputs as it stood when the drag began. +fn initial_f64(drag: &DragInput, index: usize) -> Option { + match drag.initial_parameters.get(index)? { + Some(TaggedValue::F64(value)) => Some(*value), + _ => None, + } +} + +/// The spiral is grabbable at both ends of the curve: the inner end where it starts winding and the outer +/// end where it stops. +fn spiral_turns_handles(context: &GizmoContext, _value: f64) -> Vec { + let Some((spiral_type, start_angle, inner_radius, outer_radius, turns, _)) = extract_spiral_parameters(context.layer, context.document) else { + return Vec::new(); + }; + let growth_factor = calculate_growth_factor(inner_radius, turns, outer_radius, spiral_type); + let start_angle = start_angle.to_radians(); + + vec![ + spiral_point(start_angle, inner_radius, growth_factor, spiral_type), + spiral_point(turns * TAU + start_angle, inner_radius, growth_factor, spiral_type), + ] +} + +/// Winding the spiral by dragging either end. +/// +/// Turns alone would change the spiral's tightness as it grows, so the outer radius moves with it by +/// whatever keeps the growth factor the drag started with. Taking hold of the inner end winds the other +/// way and carries the start angle along, so the end the user is *not* holding stays put. +fn spiral_turns_drag(context: &GizmoContext, drag: &DragInput) -> Vec<(ParameterRef, TaggedValue)> { + use graphene_std::vector::generator_nodes::spiral::*; + + let (Some(initial_turns), Some(initial_outer_radius), Some(initial_inner_radius), Some(initial_start_angle)) = ( + initial_f64(drag, TurnsInput::INDEX), + initial_f64(drag, OuterRadiusInput::INDEX), + initial_f64(drag, InnerRadiusInput::INDEX), + initial_f64(drag, StartAngleInput::INDEX), + ) else { + return Vec::new(); + }; + let Some((spiral_type, ..)) = extract_spiral_parameters(context.layer, context.document) else { + return Vec::new(); + }; + + let growth_factor = calculate_growth_factor(initial_inner_radius, initial_turns, initial_outer_radius, spiral_type); + let turns_delta = drag.total_angle / 360.; + + let outer_radius_change = match spiral_type { + SpiralType::Archimedean => turns_delta * growth_factor * TAU, + SpiralType::Logarithmic => initial_outer_radius * ((growth_factor * TAU * turns_delta).exp() - 1.), + }; + if !outer_radius_change.is_finite() { + return Vec::new(); + } + + // Handle 0 is the inner end of the curve; dragging it winds the spiral in the opposite direction. + let dragging_inner_end = context.handle_index == 0; + let sign = if dragging_inner_end { -1. } else { 1. }; + + // A spiral needs at least half a turn to read as one, and a non-positive outer radius has no curve. + let mut writes = vec![ + (TurnsInput.into(), TaggedValue::F64((initial_turns + turns_delta * sign).max(0.5))), + (OuterRadiusInput.into(), TaggedValue::F64((initial_outer_radius + outer_radius_change * sign).max(0.1))), + ]; + if dragging_inner_end { + writes.push((StartAngleInput.into(), TaggedValue::F64(initial_start_angle + drag.total_angle))); + } + + writes +} + +/// Mark both ends of the spiral at rest, and the end being held once one is grabbed. +fn spiral_turns_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let viewport = context.document.metadata().transform_to_viewport(context.layer); + + if context.state == GizmoState::Inactive { + for theta in [0., TAU] { + if let Some(endpoint) = calculate_spiral_endpoints(context.layer, context.document, viewport, theta) { + overlay_context.manipulator_handle(endpoint, false, None); + } + } + return; + } + + let theta = if context.handle_index == 0 { 0. } else { TAU }; + if let Some(endpoint) = calculate_spiral_endpoints(context.layer, context.document, viewport, theta) { + overlay_context.manipulator_handle(endpoint, true, Some(COLOR_OVERLAY_RED)); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 1e3ce58436..3386c68d7c 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -9,7 +9,6 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; -use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; use glam::DVec2; use std::collections::VecDeque; @@ -27,7 +26,6 @@ pub enum ShapeGizmoHandlers { None, Arc(ArcGizmoHandler), Grid(GridGizmoHandler), - Spiral(SpiralGizmoHandler), /// Registry-driven generic handler. Used for nodes that declare their gizmos in the /// [gizmo registry](super::gizmo_registry) rather than via a hand-written handler. Generic(GenericGizmoManager), @@ -40,7 +38,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(_) => "arc", Self::Grid(_) => "grid", - Self::Spiral(_) => "spiral", Self::Generic(_) => "generic", Self::None => "none", } @@ -51,7 +48,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), - Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), Self::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } @@ -62,7 +58,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), - Self::Spiral(h) => h.is_any_gizmo_hovered(), Self::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } @@ -73,7 +68,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), - Self::Spiral(h) => h.handle_click(), Self::Generic(h) => h.handle_click(), Self::None => {} } @@ -84,7 +78,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), - Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), Self::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } @@ -95,7 +88,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), - Self::Spiral(h) => h.cleanup(), Self::Generic(h) => h.cleanup(), Self::None => {} } @@ -114,7 +106,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), - Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -132,7 +123,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), - Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -142,7 +132,6 @@ impl ShapeGizmoHandlers { match self { Self::Arc(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), - Self::Spiral(h) => h.mouse_cursor_icon(), Self::Generic(h) => h.mouse_cursor_icon(), Self::None => None, } @@ -193,8 +182,9 @@ impl GizmoManager { return Some(ShapeGizmoHandlers::Grid(GridGizmoHandler::default())); } // Spiral + // Spiral — migrated to the generic, registry-driven gizmo system (winding from either endpoint). if graph_modification_utils::get_spiral_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Spiral(SpiralGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Heart — migrated to the generic, registry-driven gizmo system (radius slider). if graph_modification_utils::get_heart_id(layer, &document.network_interface).is_some() { diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index a54edd10a1..f277958828 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -82,6 +82,23 @@ pub struct GizmoContext<'a> { pub handle_index: usize, } +/// What a drag has done so far, handed to a shape's own drag function. +pub struct DragInput { + /// Where the drag began, in viewport space. + pub drag_start: DVec2, + /// Where the cursor is now, in viewport space. + pub mouse_position: DVec2, + /// The dragged parameter's value when the drag began. + pub initial_value: f64, + /// Every input of the node as it stood when the drag began, indexed the way the node's parameter symbols + /// are. A drag that rewrites several parameters at once needs this, because by the second frame the + /// values in the document are the ones it already wrote. + pub initial_parameters: Vec>, + /// Total angle swept around the layer's origin since the drag began, in degrees. Accumulated frame by + /// frame so it keeps counting past a full turn, which the angle between two points cannot express. + pub total_angle: f64, +} + /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. /// /// The generic layer always owns hit-testing, the hover/drag state machine, the handle overlay, and the @@ -107,6 +124,16 @@ pub struct GizmoBehavior { /// a star's outer radius can be taken hold of at any of its outer points — so this returns all of them. /// The drag then runs along the ray through whichever one the user grabbed, not along a fixed axis. pub handle_positions: Option Vec>, + /// How cursor motion becomes node inputs. + /// + /// The default reads the cursor's distance along the ray through the grabbed handle and writes the one + /// parameter, which is what a radius or a length wants. A shape whose control winds or sweeps rather + /// than extends supplies its own and returns every input the motion implies -- a spiral's turns cannot + /// change without its outer radius following, or the spiral would tighten as it grows. + /// + /// Returning this in place of the default also bypasses clamping and snapping, since a drag that writes + /// several parameters is the only thing that knows how they constrain each other. + pub drag: Option Vec<(ParameterRef, TaggedValue)>>, } impl GizmoBehavior { @@ -116,6 +143,7 @@ impl GizmoBehavior { overlay: None, coupled_writes: None, handle_positions: None, + drag: None, }; } @@ -223,35 +251,18 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ }, ]; -const SPIRAL_GIZMOS: &[GizmoInfo] = &[ - GizmoInfo { - parameter_index: spiral::InnerRadiusInput::INDEX, - gizmo_type: GizmoType::Slider, - name: "Inner Radius", - min: Some(0.), - max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::ParameterDerived, - }, - GizmoInfo { - parameter_index: spiral::OuterRadiusInput::INDEX, - gizmo_type: GizmoType::Slider, - name: "Outer Radius", - min: Some(0.), - max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::ParameterDerived, - }, - GizmoInfo { - parameter_index: spiral::TurnsInput::INDEX, - gizmo_type: GizmoType::Slider, - name: "Turns", - min: Some(0.), - max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::BoundingBoxEdge, - }, -]; +// Only the turns control. The inner and outer radii are reachable from the Properties panel, and a handle +// for either would sit at an arbitrary point on a curve that is nowhere near circular -- whereas winding the +// spiral from either of its own endpoints reads immediately. +const SPIRAL_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: spiral::TurnsInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Turns", + min: Some(0.), + max: None, + behavior: gizmo_behaviors::SPIRAL_TURNS, + position_hint: PositionHint::ParameterDerived, +}]; // Only the radius slider: the heart's many shaping parameters (cleavage, lobes, shoulders, point) // are fine-tuned via the Properties panel, while the overall size reads naturally as a canvas handle. diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs index 1b3bfce112..61b7efe426 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs @@ -1,4 +1,3 @@ pub mod circle_arc_radius_handle; pub mod grid_rows_columns_gizmo; -pub mod spiral_turns_handle; pub mod sweep_angle_gizmo; diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/spiral_turns_handle.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/spiral_turns_handle.rs deleted file mode 100644 index 9b297339ed..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/spiral_turns_handle.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::consts::{COLOR_OVERLAY_RED, POINT_RADIUS_HANDLE_SNAP_THRESHOLD}; -use crate::messages::message::Message; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::prelude::Responses; -use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler, NodeGraphMessage}; -use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::extract_spiral_parameters; -use crate::messages::tool::common_functionality::shapes::spiral_shape::calculate_spiral_endpoints; -use glam::DVec2; -use graph_craft::document::NodeInput; -use graph_craft::document::value::TaggedValue; -use graphene_std::vector::algorithms::shapes::{calculate_growth_factor, spiral_point}; -use graphene_std::vector::misc::SpiralType; -use std::collections::VecDeque; -use std::f64::consts::TAU; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum GizmoType { - #[default] - None, - Start, - End, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum SpiralTurnsState { - #[default] - Inactive, - Hover, - Dragging, -} - -#[derive(Clone, Debug, Default)] -pub struct SpiralTurns { - pub layer: Option, - pub handle_state: SpiralTurnsState, - initial_turns: f64, - initial_outer_radius: f64, - initial_inner_radius: f64, - initial_growth_factor: f64, - initial_start_angle: f64, - previous_mouse_position: DVec2, - total_angle_delta: f64, - gizmo_type: GizmoType, - spiral_type: SpiralType, -} - -impl SpiralTurns { - pub fn cleanup(&mut self) { - self.handle_state = SpiralTurnsState::Inactive; - self.total_angle_delta = 0.; - self.gizmo_type = GizmoType::None; - self.layer = None; - } - - pub fn update_state(&mut self, state: SpiralTurnsState) { - self.handle_state = state; - } - - pub fn hovered(&self) -> bool { - self.handle_state == SpiralTurnsState::Hover - } - - pub fn is_dragging(&self) -> bool { - self.handle_state == SpiralTurnsState::Dragging - } - - pub fn store_initial_parameters( - &mut self, - layer: LayerNodeIdentifier, - inner_radius: f64, - outer_radius: f64, - turns: f64, - start_angle: f64, - mouse_position: DVec2, - gizmo_type: GizmoType, - spiral_type: SpiralType, - ) { - self.layer = Some(layer); - self.initial_turns = turns; - self.initial_growth_factor = calculate_growth_factor(inner_radius, turns, outer_radius, spiral_type); - self.initial_inner_radius = inner_radius; - self.initial_outer_radius = outer_radius; - self.initial_start_angle = start_angle; - self.previous_mouse_position = mouse_position; - self.spiral_type = spiral_type; - self.gizmo_type = gizmo_type; - self.update_state(SpiralTurnsState::Hover); - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, _responses: &mut VecDeque) { - let viewport = document.metadata().transform_to_viewport(layer); - - match &self.handle_state { - SpiralTurnsState::Inactive => { - if let Some((spiral_type, start_angle, inner_radius, outer_radius, turns, _)) = extract_spiral_parameters(layer, document) { - let growth_factor = calculate_growth_factor(inner_radius, turns, outer_radius, spiral_type); - let end_point = viewport.transform_point2(spiral_point(turns * TAU + start_angle.to_radians(), inner_radius, growth_factor, spiral_type)); - let start_point = viewport.transform_point2(spiral_point(0. + start_angle.to_radians(), inner_radius, growth_factor, spiral_type)); - - if mouse_position.distance(end_point) < POINT_RADIUS_HANDLE_SNAP_THRESHOLD { - self.store_initial_parameters(layer, inner_radius, outer_radius, turns, start_angle, mouse_position, GizmoType::End, spiral_type); - } else if mouse_position.distance(start_point) < POINT_RADIUS_HANDLE_SNAP_THRESHOLD { - self.store_initial_parameters(layer, inner_radius, outer_radius, turns, start_angle, mouse_position, GizmoType::Start, spiral_type); - } - } - } - SpiralTurnsState::Hover | SpiralTurnsState::Dragging => {} - } - } - - pub fn overlays(&self, document: &DocumentMessageHandler, layer: Option, _shape_editor: &mut &mut ShapeState, _mouse_position: DVec2, overlay_context: &mut OverlayContext) { - let Some(layer) = layer.or(self.layer) else { return }; - let viewport = document.metadata().transform_to_viewport(layer); - - match &self.handle_state { - SpiralTurnsState::Inactive => { - if let Some((p1, p2)) = calculate_spiral_endpoints(layer, document, viewport, 0.).zip(calculate_spiral_endpoints(layer, document, viewport, TAU)) { - overlay_context.manipulator_handle(p1, false, None); - overlay_context.manipulator_handle(p2, false, None); - } - } - SpiralTurnsState::Hover | SpiralTurnsState::Dragging => { - // Is true only when hovered over the gizmo - let selected = self.layer.is_some(); - let angle = match self.gizmo_type { - GizmoType::End => TAU, - GizmoType::Start => 0., - GizmoType::None => return, - }; - - if let Some(endpoint) = calculate_spiral_endpoints(layer, document, viewport, angle) { - overlay_context.manipulator_handle(endpoint, selected, Some(COLOR_OVERLAY_RED)); - } - } - } - } - - pub fn update_number_of_turns(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - use graphene_std::vector::generator_nodes::spiral::*; - - let Some(layer) = self.layer else { - return; - }; - - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - let angle_delta = viewport - .inverse() - .transform_vector2(input.mouse.position - center) - .angle_to(viewport.inverse().transform_vector2(self.previous_mouse_position - center)) - .to_degrees(); - - // Skip update if angle calculation produced NaN or infinity (can happen when mouse is at center) - // Also skip very small angle changes to reduce jitter near center - if !angle_delta.is_finite() || angle_delta.abs() < 0.5 { - self.previous_mouse_position = input.mouse.position; - return; - } - - // Increase the number of turns and outer radius in unison such that growth and tightness remain same - let total_delta = self.total_angle_delta + angle_delta; - // Convert the total angle (in degrees) to number of full turns - let turns_delta = total_delta / 360.; - - // Calculate the new outer radius based on spiral type and turn change - let outer_radius_change = match self.spiral_type { - SpiralType::Archimedean => turns_delta * (self.initial_growth_factor) * TAU, - SpiralType::Logarithmic => self.initial_outer_radius * ((self.initial_growth_factor * TAU * turns_delta).exp() - 1.), - }; - - // Skip if outer_radius calculation produced invalid values - if !outer_radius_change.is_finite() { - return; - } - - let Some(node_id) = graph_modification_utils::get_spiral_id(layer, &document.network_interface) else { - return; - }; - - match self.gizmo_type { - GizmoType::Start => { - let sign = -1.; - let new_turns = (self.initial_turns + turns_delta * sign).max(0.5); - let new_outer_radius = (self.initial_outer_radius + outer_radius_change * sign).max(0.1); - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, StartAngleInput), - input: NodeInput::value(TaggedValue::F64(self.initial_start_angle + total_delta), false), - }); - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, TurnsInput), - input: NodeInput::value(TaggedValue::F64(new_turns), false), - }); - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, OuterRadiusInput), - input: NodeInput::value(TaggedValue::F64(new_outer_radius), false), - }); - } - GizmoType::End => { - let new_turns = (self.initial_turns + turns_delta).max(0.5); - let new_outer_radius = (self.initial_outer_radius + outer_radius_change).max(0.1); - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, TurnsInput), - input: NodeInput::value(TaggedValue::F64(new_turns), false), - }); - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, OuterRadiusInput), - input: NodeInput::value(TaggedValue::F64(new_outer_radius), false), - }); - } - GizmoType::None => { - return; - } - } - - responses.add(NodeGraphMessage::RunDocumentGraph); - self.total_angle_delta += angle_delta; - self.previous_mouse_position = input.mouse.position; - } -} diff --git a/editor/src/messages/tool/common_functionality/shapes/spiral_shape.rs b/editor/src/messages/tool/common_functionality/shapes/spiral_shape.rs index 4ff89a1666..ca369bcc1c 100644 --- a/editor/src/messages/tool/common_functionality/shapes/spiral_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/spiral_shape.rs @@ -1,13 +1,9 @@ use super::*; -use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type}; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::spiral_turns_handle::{SpiralTurns, SpiralTurnsState}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer}; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, extract_spiral_parameters}; +use crate::messages::tool::common_functionality::shapes::shape_utility::extract_spiral_parameters; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapTypeConfiguration}; use crate::messages::tool::tool_messages::shape_tool::ShapeOptionsUpdate; use crate::messages::tool::tool_messages::tool_prelude::*; @@ -18,69 +14,6 @@ use graphene_std::vector::algorithms::shapes::{calculate_growth_factor, spiral_p use graphene_std::vector::misc::SpiralType; use std::collections::VecDeque; -#[derive(Clone, Debug, Default)] -pub struct SpiralGizmoHandler { - turns_handle: SpiralTurns, -} - -impl ShapeGizmoHandler for SpiralGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - self.turns_handle.hovered() - } - - fn handle_state(&mut self, selected_spiral_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - self.turns_handle.handle_actions(selected_spiral_layer, mouse_position, document, responses); - } - - fn handle_click(&mut self) { - if self.turns_handle.hovered() { - self.turns_handle.update_state(SpiralTurnsState::Dragging); - } - } - - fn handle_update(&mut self, _drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.turns_handle.is_dragging() { - self.turns_handle.update_number_of_turns(document, input, responses); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - selected_spiral_layer: Option, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - self.turns_handle.overlays(document, selected_spiral_layer, shape_editor, mouse_position, overlay_context); - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - if self.turns_handle.is_dragging() { - self.turns_handle.overlays(document, None, shape_editor, mouse_position, overlay_context); - } - } - - fn mouse_cursor_icon(&self) -> Option { - if self.turns_handle.hovered() || self.turns_handle.is_dragging() { - return Some(MouseCursorIcon::Default); - } - None - } - - fn cleanup(&mut self) { - self.turns_handle.cleanup(); - } -} - /// Calculates the position of a spiral endpoint at a given angle offset (0 = start, TAU = end). pub fn calculate_spiral_endpoints(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, viewport: DAffine2, theta: f64) -> Option { let (spiral_type, start_angle, a, outer_radius, turns, _) = extract_spiral_parameters(layer, document)?; From 77857bdc70d8a909494707a26f48f7b5c839a44f Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:13:43 +0530 Subject: [PATCH 14/24] Migrate the Arc gizmos to the generic registry system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An arc's sweep is held to a single turn and never runs backwards, and its start angle is kept inside [-180°, 180°]. All three limits get reached by *continuing* a drag rather than by ending it, so the gesture does not stop at them -- dragging the start endpoint past a full sweep hands over to the end endpoint and carries on from there. That means a drag has to be able to rewrite the baseline the rest of the gesture is measured against, so `drag` now takes its `DragInput` mutably and the slider carries whatever the shape leaves behind into the next frame. Re-anchoring is the one thing a drag genuinely owns about itself. `GizmoType::Angle` now builds a gizmo instead of being silently skipped. It runs on the same handle machinery as a slider, since with a drag hook the two differ only in what the default would have done -- and for an angle the default, a distance along a ray, is meaningless. `angle_deadzone` moves onto the behavior for the same reason: the spiral wants its jitter guard, the arc does not. One entry covers the sweep rather than two. Dragging either endpoint can move the start angle and the sweep together, so a separate start-angle gizmo would be a second control over one gesture. Deletes `SweepAngleGizmo`, `RadiusHandle`, and `ArcGizmoHandler`. The arc's radius now uses the plain slider, as the circle's already did. Not reproduced: the red guide lines drawn at the moment a sweep snaps. The sweep still snaps to each eighth of a turn, and the angle readout still tracks it. --- .../generic_gizmos/generic_slider_gizmo.rs | 30 +- .../gizmos/generic_gizmos/mod.rs | 10 +- .../gizmos/gizmo_behaviors.rs | 223 ++++++++++- .../gizmos/gizmo_manager.rs | 14 +- .../gizmos/gizmo_registry.rs | 38 +- .../shape_gizmos/circle_arc_radius_handle.rs | 184 --------- .../gizmos/shape_gizmos/mod.rs | 2 - .../gizmos/shape_gizmos/sweep_angle_gizmo.rs | 370 ------------------ .../common_functionality/shapes/arc_shape.rs | 116 ------ 9 files changed, 266 insertions(+), 721 deletions(-) delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/circle_arc_radius_handle.rs delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/sweep_angle_gizmo.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 5b2a3c7fc2..8b92c77ab0 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -27,8 +27,6 @@ use std::collections::VecDeque; /// Pixel radius within which the mouse is considered to be hovering the handle. const SLIDER_HANDLE_HOVER_THRESHOLD: f64 = 8.; -/// Per-frame rotation below which the swept angle is treated as cursor noise rather than intent. -const ANGLE_ACCUMULATION_DEADZONE: f64 = 0.5; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum GenericSliderState { @@ -62,6 +60,8 @@ pub struct GenericSliderGizmo { previous_mouse_position: DVec2, /// Angle swept around the layer origin since the drag began, in degrees. total_angle: f64, + /// This frame's rotation about the layer origin, in degrees. + angle_delta: f64, } impl GenericSliderGizmo { @@ -78,6 +78,7 @@ impl GenericSliderGizmo { initial_parameters: Vec::new(), previous_mouse_position: DVec2::ZERO, total_angle: 0., + angle_delta: 0., } } @@ -233,15 +234,24 @@ impl GenericSliderGizmo { self.accumulate_angle(document, input.mouse.position); if let Some(drag) = self.info.behavior.drag { - let drag_input = DragInput { + let mut drag_input = DragInput { drag_start, mouse_position: input.mouse.position, initial_value: self.initial_value, initial_parameters: self.initial_parameters.clone(), total_angle: self.total_angle, + angle_delta: self.angle_delta, + handle_index: self.handle_index, }; - let writes = drag(&self.context(document, input.mouse.position, None), &drag_input); + let writes = drag(&self.context(document, input.mouse.position, None), &mut drag_input); + + // The shape may have re-anchored the gesture; carry its baseline forward. + self.total_angle = drag_input.total_angle; + self.initial_parameters = drag_input.initial_parameters; + self.handle_index = drag_input.handle_index; + self.initial_value = drag_input.initial_value; + if writes.is_empty() { return; } @@ -294,22 +304,20 @@ impl GenericSliderGizmo { /// Add this frame's rotation about the layer's origin to the running total, so a drag that winds several /// times around keeps counting instead of wrapping at half a turn. /// - /// Rotations smaller than half a degree are dropped: near the origin the angle between successive cursor - /// positions is dominated by noise, and feeding that in makes the value jitter while the cursor is still. + /// Rotations inside the behavior's deadzone are dropped; see `GizmoBehavior::angle_deadzone`. fn accumulate_angle(&mut self, document: &DocumentMessageHandler, mouse_position: DVec2) { let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); let inverse = viewport.inverse(); let delta = inverse - .transform_vector2(mouse_position - center) - .angle_to(inverse.transform_vector2(self.previous_mouse_position - center)) + .transform_vector2(self.previous_mouse_position - center) + .angle_to(inverse.transform_vector2(mouse_position - center)) .to_degrees(); self.previous_mouse_position = mouse_position; - if delta.is_finite() && delta.abs() >= ANGLE_ACCUMULATION_DEADZONE { - self.total_angle += delta; - } + self.angle_delta = if delta.is_finite() && delta.abs() >= self.info.behavior.angle_deadzone { delta } else { 0. }; + self.total_angle += self.angle_delta; } /// Pull the value onto the nearest snap target within the threshold. Returns the value unchanged when diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs index fccf4950f2..c3ce1e0c31 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -157,11 +157,13 @@ impl GenericGizmoManager { let mut gizmos = Vec::new(); for info in infos { match info.gizmo_type { - GizmoType::Slider => gizmos.push(GenericGizmo::Slider(GenericSliderGizmo::new(layer, node_id, identifier.clone(), *info))), + // An angle runs on the same handle machinery as a slider; what differs is the drag, which + // its registry entry supplies. + GizmoType::Slider | GizmoType::Angle => gizmos.push(GenericGizmo::Slider(GenericSliderGizmo::new(layer, node_id, identifier.clone(), *info))), GizmoType::Dial => gizmos.push(GenericGizmo::Dial(GenericDialGizmo::new(layer, node_id, identifier.clone(), *info))), - // Position and Angle gizmos are not yet implemented; they are skipped so a - // partially-migrated node still gets its slider/dial controls. - GizmoType::Position | GizmoType::Angle => {} + // Position gizmos are not yet implemented; they are skipped so a partially-migrated node + // still gets its other controls. + GizmoType::Position => {} } } diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 85adac8bcc..5e5a5946b4 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -9,14 +9,15 @@ //! count and its *other* radius, and no amount of registry data expresses that. Those live here as plain //! functions, referenced from the [registry](super::gizmo_registry) table. -use crate::consts::COLOR_OVERLAY_RED; +use crate::consts::{ARC_SNAP_THRESHOLD, COLOR_OVERLAY_RED}; use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; +use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, GizmoBehavior, GizmoContext, GizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::shapes::shape_utility::{ - draw_snapping_ticks, extract_polygon_parameters, extract_spiral_parameters, extract_star_parameters, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, - star_vertex_position, + arc_end_points, arc_end_points_ignore_layer, arc_outline, calculate_arc_text_transform, draw_snapping_ticks, extract_arc_parameters, extract_polygon_parameters, extract_spiral_parameters, + extract_star_parameters, format_rounded, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, }; use crate::messages::tool::common_functionality::shapes::spiral_shape::calculate_spiral_endpoints; use glam::{DAffine2, DVec2}; @@ -35,6 +36,7 @@ pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { coupled_writes: None, handle_positions: None, drag: None, + angle_deadzone: 0., }; /// Either of the star's radius handles: snaps to the radii where the star's points line up, and previews @@ -45,6 +47,18 @@ pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { coupled_writes: None, handle_positions: Some(star_radius_handles), drag: None, + angle_deadzone: 0., +}; + +/// The arc's sweep, grabbable at either end of the curve. Dragging either endpoint reshapes the arc; the +/// start endpoint carries the whole arc round with it, the end endpoint only opens or closes the sweep. +pub const ARC_SWEEP: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(arc_sweep_overlay), + coupled_writes: None, + handle_positions: Some(arc_sweep_handles), + drag: Some(arc_sweep_drag), + angle_deadzone: 0., }; /// The spiral's winding control. Grabbable at either end of the curve; dragging winds or unwinds it. @@ -54,6 +68,7 @@ pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { coupled_writes: None, handle_positions: Some(spiral_turns_handles), drag: Some(spiral_turns_drag), + angle_deadzone: 0.5, }; /// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. @@ -63,6 +78,7 @@ pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { coupled_writes: None, handle_positions: None, drag: None, + angle_deadzone: 0., }; /// The radii at which dragging one of a star's radius handles makes its points line up: the value where @@ -262,7 +278,12 @@ fn draw_spokes(viewport: DAffine2, sides: u32, radius: f64, state: GizmoState, o } } -/// Read one of the spiral's inputs as it stood when the drag began. +/// The spiral winds the opposite way round from the shared accumulator's sense of positive rotation. +fn spiral_swept_angle(drag: &DragInput) -> f64 { + -drag.total_angle +} + +/// Read one of the node's inputs as it stood when the drag began. fn initial_f64(drag: &DragInput, index: usize) -> Option { match drag.initial_parameters.get(index)? { Some(TaggedValue::F64(value)) => Some(*value), @@ -290,7 +311,7 @@ fn spiral_turns_handles(context: &GizmoContext, _value: f64) -> Vec { /// Turns alone would change the spiral's tightness as it grows, so the outer radius moves with it by /// whatever keeps the growth factor the drag started with. Taking hold of the inner end winds the other /// way and carries the start angle along, so the end the user is *not* holding stays put. -fn spiral_turns_drag(context: &GizmoContext, drag: &DragInput) -> Vec<(ParameterRef, TaggedValue)> { +fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(ParameterRef, TaggedValue)> { use graphene_std::vector::generator_nodes::spiral::*; let (Some(initial_turns), Some(initial_outer_radius), Some(initial_inner_radius), Some(initial_start_angle)) = ( @@ -306,7 +327,7 @@ fn spiral_turns_drag(context: &GizmoContext, drag: &DragInput) -> Vec<(Parameter }; let growth_factor = calculate_growth_factor(initial_inner_radius, initial_turns, initial_outer_radius, spiral_type); - let turns_delta = drag.total_angle / 360.; + let turns_delta = spiral_swept_angle(drag) / 360.; let outer_radius_change = match spiral_type { SpiralType::Archimedean => turns_delta * growth_factor * TAU, @@ -326,7 +347,7 @@ fn spiral_turns_drag(context: &GizmoContext, drag: &DragInput) -> Vec<(Parameter (OuterRadiusInput.into(), TaggedValue::F64((initial_outer_radius + outer_radius_change * sign).max(0.1))), ]; if dragging_inner_end { - writes.push((StartAngleInput.into(), TaggedValue::F64(initial_start_angle + drag.total_angle))); + writes.push((StartAngleInput.into(), TaggedValue::F64(initial_start_angle + spiral_swept_angle(drag)))); } writes @@ -350,3 +371,191 @@ fn spiral_turns_overlay(context: &GizmoContext, overlay_context: &mut OverlayCon overlay_context.manipulator_handle(endpoint, true, Some(COLOR_OVERLAY_RED)); } } + +/// Overwrite one of the drag's remembered starting values, for a gesture that has to re-anchor itself. +fn set_initial(drag: &mut DragInput, index: usize, value: f64) { + if let Some(slot) = drag.initial_parameters.get_mut(index) { + *slot = Some(TaggedValue::F64(value)); + } +} + +/// The sweep is grabbable at both ends of the arc. +fn arc_sweep_handles(context: &GizmoContext, _value: f64) -> Vec { + let Some((radius, start_angle, sweep_angle, _)) = extract_arc_parameters(Some(context.layer), context.document) else { + return Vec::new(); + }; + let Some((start, end)) = arc_end_points_ignore_layer(radius, start_angle, sweep_angle, None) else { + return Vec::new(); + }; + + vec![start, end] +} + +/// The angles a sweep settles onto: every eighth of a turn, from closed to fully round. +fn arc_snap_angles() -> Vec { + (0..=8).map(|i| (i as f64 * FRAC_PI_4).to_degrees()).collect() +} + +/// How far the sweep must move to land on the nearest snap angle, or `None` if none is close enough. +fn arc_snap_delta(sweep_angle: f64, dragging_start: bool) -> Option { + arc_snap_angles().into_iter().find(|angle| (angle - sweep_angle).abs() <= ARC_SNAP_THRESHOLD).map(|angle| { + let delta = angle - sweep_angle; + // Dragging the start endpoint moves the sweep the opposite way from the cursor. + if dragging_start { -delta } else { delta } + }) +} + +/// Reshape the arc by dragging one of its endpoints. +/// +/// The sweep is held to a single turn and never runs backwards, and the start angle is kept inside +/// [-180°, 180°]. Both limits are reached by *continuing* a drag rather than ending it, so rather than +/// stopping at the limit the gesture re-anchors: dragging the start endpoint past a full sweep hands over +/// to the end endpoint and carries on from there, which is why the baseline is rewritten as it goes. +fn arc_sweep_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(ParameterRef, TaggedValue)> { + use graphene_std::vector::generator_nodes::arc::*; + + let Some((_, current_start_angle, current_sweep_angle, _)) = extract_arc_parameters(Some(context.layer), context.document) else { + return Vec::new(); + }; + let (Some(initial_start_angle), Some(initial_sweep_angle)) = (initial_f64(drag, StartAngleInput::INDEX), initial_f64(drag, SweepAngleInput::INDEX)) else { + return Vec::new(); + }; + + let angle_delta = drag.angle_delta; + let angle = drag.total_angle; + let dragging_start = drag.handle_index == 0; + + let write = |start: f64, sweep: f64| vec![(StartAngleInput.into(), TaggedValue::F64(start)), (SweepAngleInput.into(), TaggedValue::F64(sweep))]; + + if dragging_start { + // The start endpoint drags the whole arc round, so the sweep closes by as much as the start opens. + let sign = -angle.signum(); + let new_start_angle = initial_start_angle + angle; + let new_sweep_angle = initial_sweep_angle + angle.abs() * sign; + + if new_sweep_angle > 360. { + // Sweep closed all the way round: hand over to the end endpoint and continue from a full turn. + let wrapped = new_sweep_angle % 360.; + drag.total_angle = -wrapped; + drag.handle_index = 1; + set_initial(drag, SweepAngleInput::INDEX, 360.); + set_initial(drag, StartAngleInput::INDEX, current_start_angle); + + return write(current_start_angle, 360. - wrapped); + } + if new_sweep_angle < 0. { + // Sweep closed to nothing: hand over to the end endpoint and reopen from there. + let rest_angle = angle_delta + new_sweep_angle; + drag.total_angle = new_sweep_angle.abs(); + drag.handle_index = 1; + set_initial(drag, SweepAngleInput::INDEX, 0.); + set_initial(drag, StartAngleInput::INDEX, current_start_angle + rest_angle); + + return write(current_start_angle + rest_angle, new_sweep_angle.abs()); + } + if new_start_angle > 180. { + // Start angle ran off the top of its range: jump it to the bottom and shrink the sweep to match. + let overflow = new_start_angle % 180.; + let rest_angle = angle_delta - overflow; + drag.total_angle = rest_angle; + set_initial(drag, StartAngleInput::INDEX, -180.); + set_initial(drag, SweepAngleInput::INDEX, current_sweep_angle - rest_angle); + + return write(-180. + overflow, current_sweep_angle - rest_angle - overflow); + } + if new_start_angle < -180. { + // Same in the other direction: the start wraps to the top and the sweep grows to match. + let underflow = new_start_angle % 180.; + let rest_angle = angle_delta - underflow; + drag.total_angle = underflow; + set_initial(drag, StartAngleInput::INDEX, 180.); + set_initial(drag, SweepAngleInput::INDEX, current_sweep_angle + rest_angle.abs()); + + return write(180. + underflow, current_sweep_angle + rest_angle.abs() + underflow.abs()); + } + + let mut total = angle; + if let Some(snapped) = arc_snap_delta(initial_sweep_angle + angle.abs() * sign, true) { + total += snapped; + } + + return write(initial_start_angle + total, initial_sweep_angle + total.abs() * sign); + } + + // The end endpoint only opens or closes the sweep; the start stays put. + let new_sweep_angle = initial_sweep_angle + angle; + + if new_sweep_angle < 0. { + // Closed past nothing: hand back to the start endpoint, which reopens it the other way. + let delta = angle_delta - current_sweep_angle; + let sign = -delta.signum(); + drag.total_angle = delta; + drag.handle_index = 0; + set_initial(drag, SweepAngleInput::INDEX, 0.); + + return write(initial_start_angle + delta, delta.abs() * sign); + } + if new_sweep_angle > 360. { + // Opened past a full turn: hand back to the start endpoint from a full sweep. + let delta = angle_delta - (360. - new_sweep_angle); + let sign = -delta.signum(); + drag.total_angle = delta; + drag.handle_index = 0; + set_initial(drag, SweepAngleInput::INDEX, 360.); + + return write(initial_start_angle + angle_delta, 360. + angle_delta.abs() * sign); + } + + let mut total = angle; + if let Some(snapped) = arc_snap_delta(initial_sweep_angle + angle, false) { + total += snapped; + } + + write(initial_start_angle, initial_sweep_angle + total) +} + +/// Mark both endpoints at rest, highlight the one under the cursor, and while dragging show the sweep being +/// described: the arc between where the endpoint started and where it is now, labelled with its angle. +fn arc_sweep_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let Some((current_start, current_end)) = arc_end_points(Some(context.layer), context.document) else { + return; + }; + + if context.state == GizmoState::Inactive { + overlay_context.manipulator_handle(current_start, false, None); + overlay_context.manipulator_handle(current_end, false, None); + return; + } + + let dragging_start = context.handle_index == 0; + let (point, other_point) = if dragging_start { (current_start, current_end) } else { (current_end, current_start) }; + + // The outline shows the whole arc responding, not just the endpoint being held. + arc_outline(Some(context.layer), context.document, overlay_context); + + if context.state == GizmoState::Hover { + overlay_context.manipulator_handle(point, true, None); + overlay_context.manipulator_handle(other_point, false, None); + return; + } + + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + overlay_context.manipulator_handle(other_point, false, None); + overlay_context.dashed_line(other_point, center, None, None, Some(5.), Some(5.), Some(0.5)); + + // The sweep readout runs from the endpoint the user is not holding to the one they are. + let tilt_offset = context.document.document_ptz.unmodified_tilt(); + let initial_vector = other_point - center; + let final_vector = point - center; + let offset_angle = initial_vector.to_angle() + tilt_offset; + let angle = initial_vector.angle_to(final_vector).to_degrees(); + let display_angle = viewport.inverse().transform_point2(point).angle_to(viewport.inverse().transform_point2(other_point)).to_degrees(); + + let text = format!("{}°", format_rounded(display_angle, 2)); + const FONT_SIZE: f64 = 12.; + let transform = calculate_arc_text_transform(angle, offset_angle, center, text_width(&text, FONT_SIZE) / 2.); + + overlay_context.arc_sweep_angle(offset_angle, angle, point, point.distance(center), center, &text, transform); +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 3386c68d7c..eeae863ea8 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -6,7 +6,6 @@ use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageH use crate::messages::tool::common_functionality::gizmos::generic_gizmos::GenericGizmoManager; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use glam::DVec2; @@ -24,7 +23,6 @@ use std::collections::VecDeque; pub enum ShapeGizmoHandlers { #[default] None, - Arc(ArcGizmoHandler), Grid(GridGizmoHandler), /// Registry-driven generic handler. Used for nodes that declare their gizmos in the /// [gizmo registry](super::gizmo_registry) rather than via a hand-written handler. @@ -36,7 +34,6 @@ impl ShapeGizmoHandlers { /// Used for grouping logic and distinguishing between handler types at runtime. pub fn kind(&self) -> &'static str { match self { - Self::Arc(_) => "arc", Self::Grid(_) => "grid", Self::Generic(_) => "generic", Self::None => "none", @@ -46,7 +43,6 @@ impl ShapeGizmoHandlers { /// Dispatches interaction state updates to the corresponding shape-specific handler. pub fn handle_state(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { match self { - Self::Arc(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} @@ -56,7 +52,6 @@ impl ShapeGizmoHandlers { /// Checks if any interactive part of the gizmo is currently hovered. pub fn is_any_gizmo_hovered(&self) -> bool { match self { - Self::Arc(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, @@ -66,7 +61,6 @@ impl ShapeGizmoHandlers { /// Passes the click interaction to the appropriate gizmo handler if one is hovered. pub fn handle_click(&mut self) { match self { - Self::Arc(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Generic(h) => h.handle_click(), Self::None => {} @@ -76,7 +70,6 @@ impl ShapeGizmoHandlers { /// Updates the gizmo state while the user is dragging a handle (e.g., adjusting radius). pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { match self { - Self::Arc(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} @@ -86,7 +79,6 @@ impl ShapeGizmoHandlers { /// Cleans up any state used by the gizmo handler. pub fn cleanup(&mut self) { match self { - Self::Arc(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Generic(h) => h.cleanup(), Self::None => {} @@ -104,7 +96,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Arc(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} @@ -121,7 +112,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Arc(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} @@ -130,7 +120,6 @@ impl ShapeGizmoHandlers { pub fn gizmo_cursor_icon(&self) -> Option { match self { - Self::Arc(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Generic(h) => h.mouse_cursor_icon(), Self::None => None, @@ -170,8 +159,9 @@ impl GizmoManager { return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Arc + // Arc — migrated to the generic, registry-driven gizmo system (radius slider, sweep from either endpoint). if graph_modification_utils::get_arc_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Arc(ArcGizmoHandler::new())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Circle — migrated to the generic, registry-driven gizmo system (radius slider). if graph_modification_utils::get_circle_id(layer, &document.network_interface).is_some() { diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index f277958828..61d0a61351 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -34,7 +34,9 @@ pub enum GizmoType { Dial, /// A draggable point that edits a `DVec2` parameter (e.g. a position or 2D spacing). Position, - /// A draggable handle constrained to a circle that edits an angle, stored as `f64` degrees. + /// A handle that edits an angle, stored as `f64` degrees. Driven by the same handle machinery as + /// [`Slider`](Self::Slider), but an angle is never a distance along a ray, so a declaration using this + /// is expected to carry a [`GizmoBehavior::drag`] rather than rely on the default. Angle, } @@ -97,6 +99,10 @@ pub struct DragInput { /// Total angle swept around the layer's origin since the drag began, in degrees. Accumulated frame by /// frame so it keeps counting past a full turn, which the angle between two points cannot express. pub total_angle: f64, + /// This frame's rotation about the layer's origin, in degrees. + pub angle_delta: f64, + /// Which grab point the drag is running from. + pub handle_index: usize, } /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. @@ -133,7 +139,15 @@ pub struct GizmoBehavior { /// /// Returning this in place of the default also bypasses clamping and snapping, since a drag that writes /// several parameters is the only thing that knows how they constrain each other. - pub drag: Option Vec<(ParameterRef, TaggedValue)>>, + /// + /// The [`DragInput`] is mutable because a drag may have to re-anchor itself: an arc dragged past a full + /// sweep hands over to its other endpoint and continues from there, which means rewriting the baseline + /// the rest of the gesture is measured against. + pub drag: Option Vec<(ParameterRef, TaggedValue)>>, + /// Per-frame rotation, in degrees, below which swept angle is treated as cursor noise rather than + /// intent. Near the layer's origin the angle between successive cursor positions is mostly noise, and + /// feeding it in makes the value jitter while the cursor is still. Zero accumulates everything. + pub angle_deadzone: f64, } impl GizmoBehavior { @@ -144,6 +158,7 @@ impl GizmoBehavior { coupled_writes: None, handle_positions: None, drag: None, + angle_deadzone: 0., }; } @@ -231,22 +246,15 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ behavior: GizmoBehavior::NONE, position_hint: PositionHint::ParameterDerived, }, - GizmoInfo { - parameter_index: arc::StartAngleInput::INDEX, - gizmo_type: GizmoType::Angle, - name: "Start Angle", - min: None, - max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::ParameterDerived, - }, + // One entry, not two: dragging either endpoint can move the start angle and the sweep together, so a + // separate start-angle gizmo would be a second control over the same gesture. GizmoInfo { parameter_index: arc::SweepAngleInput::INDEX, gizmo_type: GizmoType::Angle, - name: "Sweep Angle", - min: None, - max: None, - behavior: GizmoBehavior::NONE, + name: "Sweep", + min: Some(0.), + max: Some(360.), + behavior: gizmo_behaviors::ARC_SWEEP, position_hint: PositionHint::ParameterDerived, }, ]; diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/circle_arc_radius_handle.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/circle_arc_radius_handle.rs deleted file mode 100644 index 837a1100aa..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/circle_arc_radius_handle.rs +++ /dev/null @@ -1,184 +0,0 @@ -use crate::consts::GIZMO_HIDE_THRESHOLD; -use crate::messages::frontend::utility_types::MouseCursorIcon; -use crate::messages::message::Message; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler, NodeGraphMessage}; -use crate::messages::prelude::{FrontendMessage, Responses}; -use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_arc_id, get_stroke_width}; -use crate::messages::tool::common_functionality::shapes::shape_utility::{extract_arc_parameters, extract_circle_radius}; -use glam::{DAffine2, DVec2}; -use graph_craft::document::NodeInput; -use graph_craft::document::value::TaggedValue; -use graphene_std::ParameterRef; -use std::collections::VecDeque; -use std::f64::consts::FRAC_PI_2; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum RadiusHandleState { - #[default] - Inactive, - Hover, - Dragging, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct RadiusHandle { - pub layer: Option, - initial_radius: f64, - handle_state: RadiusHandleState, - angle: f64, - previous_mouse_position: DVec2, -} - -impl RadiusHandle { - pub fn cleanup(&mut self) { - self.handle_state = RadiusHandleState::Inactive; - self.layer = None; - } - - pub fn hovered(&self) -> bool { - self.handle_state == RadiusHandleState::Hover - } - - pub fn is_dragging(&self) -> bool { - self.handle_state == RadiusHandleState::Dragging - } - - pub fn update_state(&mut self, state: RadiusHandleState) { - self.handle_state = state; - } - - pub fn check_if_inside_dash_lines(angle: f64, mouse_position: DVec2, viewport: DAffine2, radius: f64, document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> bool { - let center = viewport.transform_point2(DVec2::ZERO); - if let Some(stroke_width) = get_stroke_width(layer, &document.network_interface) { - let circle_point = calculate_circle_point_position(angle, radius.abs()); - let Some(direction) = circle_point.try_normalize() else { return false }; - let mouse_distance = mouse_position.distance(center); - - let spacing = Self::calculate_extra_spacing(viewport, radius, center, stroke_width, 15.); - - let inner_point = viewport.transform_point2(circle_point - direction * spacing).distance(center); - let outer_point = viewport.transform_point2(circle_point + direction * spacing).distance(center); - - mouse_distance >= inner_point && mouse_distance <= outer_point - } else { - let point_position = viewport.transform_point2(calculate_circle_point_position(angle, radius.abs())); - mouse_position.distance(center) <= point_position.distance(center) - } - } - - fn calculate_extra_spacing(viewport: DAffine2, radius: f64, viewport_center: DVec2, stroke_width: f64, threshold: f64) -> f64 { - let start_point = viewport.transform_point2(calculate_circle_point_position(0., radius)).distance(viewport_center); - let end_point = viewport.transform_point2(calculate_circle_point_position(FRAC_PI_2, radius)).distance(viewport_center); - let min_radius = start_point.min(end_point); - let extra_spacing = if min_radius < threshold { 10. * (min_radius / threshold) } else { 10. }; - - stroke_width + extra_spacing - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, document: &DocumentMessageHandler, mouse_position: DVec2, responses: &mut VecDeque) { - match &self.handle_state { - RadiusHandleState::Inactive => { - let Some(radius) = extract_circle_radius(layer, document).or(extract_arc_parameters(Some(layer), document).map(|(r, _, _, _)| r)) else { - return; - }; - let viewport = document.metadata().transform_to_viewport(layer); - let angle = viewport.inverse().transform_point2(mouse_position).angle_to(DVec2::X); - let point_position = viewport.transform_point2(calculate_circle_point_position(angle, radius.abs())); - let center = viewport.transform_point2(DVec2::ZERO); - - if point_position.distance(center) < GIZMO_HIDE_THRESHOLD { - return; - } - - if Self::check_if_inside_dash_lines(angle, mouse_position, viewport, radius.abs(), document, layer) { - self.layer = Some(layer); - self.initial_radius = radius; - self.previous_mouse_position = mouse_position; - self.angle = angle; - - self.update_state(RadiusHandleState::Hover); - - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize }); - } - } - RadiusHandleState::Dragging | RadiusHandleState::Hover => {} - } - } - - pub fn overlays(&self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { - match &self.handle_state { - RadiusHandleState::Inactive => {} - RadiusHandleState::Dragging | RadiusHandleState::Hover => { - let Some(layer) = self.layer else { return }; - let Some(radius) = extract_circle_radius(layer, document).or(extract_arc_parameters(Some(layer), document).map(|(r, _, _, _)| r)) else { - return; - }; - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - let x_point = viewport.transform_point2(calculate_circle_point_position(0., radius)); - let y_point = viewport.transform_point2(calculate_circle_point_position(FRAC_PI_2, radius)); - - let direction_x = viewport.transform_vector2(DVec2::X); - let direction_y = viewport.transform_vector2(-DVec2::Y); - - if let Some(stroke_width) = get_stroke_width(layer, &document.network_interface) { - let spacing = Self::calculate_extra_spacing(viewport, radius, center, stroke_width, 15.); - let smaller_radius_x = (x_point - direction_x * spacing).distance(center); - let smaller_radius_y = (y_point - direction_y * spacing).distance(center); - - let larger_radius_x = (x_point + direction_x * spacing).distance(center); - let larger_radius_y = (y_point + direction_y * spacing).distance(center); - - overlay_context.dashed_ellipse(center, smaller_radius_x, smaller_radius_y, None, None, None, None, None, None, Some(4.), Some(4.), Some(0.5)); - overlay_context.dashed_ellipse(center, larger_radius_x, larger_radius_y, None, None, None, None, None, None, Some(4.), Some(4.), Some(0.5)); - - return; - } - - let radius_x = x_point.distance(center); - let radius_y = y_point.distance(center); - overlay_context.dashed_ellipse(center, radius_x, radius_y, None, None, None, None, None, None, Some(4.), Some(4.), Some(0.5)); - } - } - } - - pub fn update_inner_radius(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, drag_start: DVec2) { - let Some(layer) = self.layer else { return }; - - // This gizmo serves both Circle and Arc layers, so resolve which node is present to know whose radius parameter to write - let (node_id, radius_parameter) = if let Some(node_id) = graph_modification_utils::get_circle_id(layer, &document.network_interface) { - (node_id, ParameterRef::from(graphene_std::vector::generator_nodes::circle::RadiusInput)) - } else if let Some(node_id) = get_arc_id(layer, &document.network_interface) { - (node_id, ParameterRef::from(graphene_std::vector::generator_nodes::arc::RadiusInput)) - } else { - return; - }; - let Some(current_radius) = extract_circle_radius(layer, document).or(extract_arc_parameters(Some(layer), document).map(|(r, _, _, _)| r)) else { - return; - }; - - let viewport_transform = document.network_interface.document_metadata().transform_to_viewport(layer); - let center = viewport_transform.transform_point2(DVec2::ZERO); - - let delta_vector = viewport_transform.inverse().transform_point2(input.mouse.position) - viewport_transform.inverse().transform_point2(self.previous_mouse_position); - let radius = drag_start - center; - let sign = radius.dot(delta_vector).signum(); - - let net_delta = delta_vector.length() * sign * self.initial_radius.signum(); - self.previous_mouse_position = input.mouse.position; - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, radius_parameter), - input: NodeInput::value(TaggedValue::F64(current_radius + net_delta), false), - }); - responses.add(NodeGraphMessage::RunDocumentGraph); - } -} - -fn calculate_circle_point_position(theta: f64, radius: f64) -> DVec2 { - DVec2::new(radius * theta.cos(), -radius * theta.sin()) -} diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs index 61b7efe426..0ef1b606b7 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs @@ -1,3 +1 @@ -pub mod circle_arc_radius_handle; pub mod grid_rows_columns_gizmo; -pub mod sweep_angle_gizmo; diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/sweep_angle_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/sweep_angle_gizmo.rs deleted file mode 100644 index c65b912aba..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/sweep_angle_gizmo.rs +++ /dev/null @@ -1,370 +0,0 @@ -use crate::consts::{ARC_SNAP_THRESHOLD, GIZMO_HIDE_THRESHOLD}; -use crate::messages::message::Message; -use crate::messages::portfolio::document::overlays::utility_functions::text_width; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::prelude::DocumentMessageHandler; -use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shapes::shape_utility::{arc_end_points, calculate_arc_text_transform, extract_arc_parameters, format_rounded}; -use crate::messages::tool::tool_messages::tool_prelude::*; -use glam::DVec2; -use graph_craft::document::value::TaggedValue; -use graph_craft::document::{NodeId, NodeInput}; -use std::collections::VecDeque; -use std::f64::consts::FRAC_PI_4; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum SweepAngleGizmoState { - #[default] - Inactive, - Hover, - Dragging, - Snapped, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum EndpointType { - #[default] - None, - Start, - End, -} - -#[derive(Clone, Debug, Default)] -pub struct SweepAngleGizmo { - pub layer: Option, - endpoint: EndpointType, - initial_start_angle: f64, - initial_sweep_angle: f64, - position_before_rotation: DVec2, - previous_mouse_position: DVec2, - total_angle_delta: f64, - snap_angles: Vec, - handle_state: SweepAngleGizmoState, -} - -impl SweepAngleGizmo { - pub fn hovered(&self) -> bool { - self.handle_state == SweepAngleGizmoState::Hover - } - - pub fn update_state(&mut self, state: SweepAngleGizmoState) { - self.handle_state = state; - } - - pub fn is_dragging_or_snapped(&self) -> bool { - self.handle_state == SweepAngleGizmoState::Dragging || self.handle_state == SweepAngleGizmoState::Snapped - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, document: &DocumentMessageHandler, mouse_position: DVec2) { - if self.handle_state == SweepAngleGizmoState::Inactive { - let Some((start, end)) = arc_end_points(Some(layer), document) else { return }; - let Some((_, start_angle, sweep_angle, _)) = extract_arc_parameters(Some(layer), document) else { - return; - }; - - let center = document.metadata().transform_to_viewport(layer).transform_point2(DVec2::ZERO); - - if center.distance(start) < GIZMO_HIDE_THRESHOLD { - return; - } - - let (close_to_gizmo, endpoint_type) = if mouse_position.distance(start) < 5. { - (true, EndpointType::Start) - } else if mouse_position.distance(end) < 5. { - (true, EndpointType::End) - } else { - (false, EndpointType::None) - }; - - if close_to_gizmo { - self.layer = Some(layer); - self.initial_start_angle = start_angle; - self.initial_sweep_angle = sweep_angle; - self.previous_mouse_position = mouse_position; - self.total_angle_delta = 0.; - self.position_before_rotation = if endpoint_type == EndpointType::End { end } else { start }; - self.endpoint = endpoint_type; - self.snap_angles = Self::calculate_snap_angles(); - - self.update_state(SweepAngleGizmoState::Hover); - } - } - } - - pub fn overlays( - &self, - selected_arc_layer: Option, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - _mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - let tilt_offset = document.document_ptz.unmodified_tilt(); - - match self.handle_state { - SweepAngleGizmoState::Inactive => { - let Some((point1, point2)) = arc_end_points(selected_arc_layer, document) else { return }; - overlay_context.manipulator_handle(point1, false, None); - overlay_context.manipulator_handle(point2, false, None); - } - SweepAngleGizmoState::Hover => { - // Highlight the currently hovered endpoint only - let Some((point1, point2)) = arc_end_points(self.layer, document) else { return }; - - let (point, other_point) = if self.endpoint == EndpointType::Start { (point1, point2) } else { (point2, point1) }; - overlay_context.manipulator_handle(point, true, None); - overlay_context.manipulator_handle(other_point, false, None); - } - SweepAngleGizmoState::Dragging => { - // Show snapping guides and angle arc while dragging - let Some(layer) = self.layer else { return }; - let Some((current_start, current_end)) = arc_end_points(self.layer, document) else { return }; - let viewport = document.metadata().transform_to_viewport(layer); - - // Depending on which endpoint is being dragged, draw guides relative to the static point - let (point, other_point) = if self.endpoint == EndpointType::End { - (current_end, current_start) - } else { - (current_start, current_end) - }; - - // Draw the dashed line from center to drag start position - overlay_context.dashed_line(self.position_before_rotation, viewport.transform_point2(DVec2::ZERO), None, None, Some(5.), Some(5.), Some(0.5)); - - overlay_context.manipulator_handle(other_point, false, None); - - // Draw the angle, text and the bold line - self.dragging_snapping_overlays(self.position_before_rotation, point, tilt_offset, viewport, overlay_context); - } - SweepAngleGizmoState::Snapped => { - // When snapping is active, draw snapping arcs and angular guidelines - let Some((start, end)) = arc_end_points(self.layer, document) else { return }; - let Some(layer) = self.layer else { return }; - let viewport = document.metadata().transform_to_viewport(layer); - let center = viewport.transform_point2(DVec2::ZERO); - - // Draw snapping arc and angle overlays between the two points - let (a, b) = if self.endpoint == EndpointType::Start { (end, start) } else { (start, end) }; - self.dragging_snapping_overlays(a, b, tilt_offset, viewport, overlay_context); - - // Draw lines from endpoints to the arc center - overlay_context.line(start, center, None, Some(2.)); - overlay_context.line(end, center, None, Some(2.)); - - // Draw the line from drag start to arc center - overlay_context.dashed_line(self.position_before_rotation, center, None, None, Some(5.), Some(5.), Some(0.5)); - } - } - } - - /// Draws the visual overlay during arc handle dragging or snapping interactions. - /// This includes the dynamic arc sweep, angle label, and visual guides centered around the arc's origin. - pub fn dragging_snapping_overlays(&self, initial_point: DVec2, final_point: DVec2, tilt_offset: f64, viewport: DAffine2, overlay_context: &mut OverlayContext) { - let center = viewport.transform_point2(DVec2::ZERO); - let initial_vector = initial_point - center; - let final_vector = final_point - center; - let offset_angle = initial_vector.to_angle() + tilt_offset; - - let bold_radius = final_point.distance(center); - - let angle = initial_vector.angle_to(final_vector).to_degrees(); - let display_angle = viewport - .inverse() - .transform_point2(final_point) - .angle_to(viewport.inverse().transform_point2(initial_point)) - .to_degrees(); - - let text = format!("{}°", format_rounded(display_angle, 2)); - const FONT_SIZE: f64 = 12.; - - let text_width = text_width(&text, FONT_SIZE); - - let text_texture_width = text_width / 2.; - - let transform = calculate_arc_text_transform(angle, offset_angle, center, text_texture_width); - - overlay_context.arc_sweep_angle(offset_angle, angle, final_point, bold_radius, center, &text, transform); - } - - pub fn update_arc(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - let Some(layer) = self.layer else { return }; - let Some((_, current_start_angle, current_sweep_angle, _)) = extract_arc_parameters(Some(layer), document) else { - return; - }; - - let viewport = document.metadata().transform_to_viewport(layer); - let angle_delta = viewport - .inverse() - .transform_point2(self.previous_mouse_position) - .angle_to(viewport.inverse().transform_point2(input.mouse.position)) - .to_degrees(); - let angle = self.total_angle_delta + angle_delta; - - let Some(node_id) = graph_modification_utils::get_arc_id(layer, &document.network_interface) else { - return; - }; - - self.update_state(SweepAngleGizmoState::Dragging); - - match self.endpoint { - EndpointType::Start => { - // Dragging start changes both start and sweep - - let sign = -angle.signum(); - let mut total = angle; - - let new_start_angle = self.initial_start_angle + total; - let new_sweep_angle = self.initial_sweep_angle + total.abs() * sign; - - match () { - // Clamp sweep angle to 360° - () if new_sweep_angle > 360. => { - let wrapped = new_sweep_angle % 360.; - self.total_angle_delta = -wrapped; - - self.endpoint = EndpointType::End; - - self.initial_sweep_angle = 360.; - self.initial_start_angle = current_start_angle; - self.update_state(SweepAngleGizmoState::Snapped); - - self.apply_arc_update(node_id, self.initial_start_angle, self.initial_sweep_angle - wrapped, input, responses); - } - () if new_sweep_angle < 0. => { - let rest_angle = angle_delta + new_sweep_angle; - - self.total_angle_delta = new_sweep_angle.abs(); - self.endpoint = EndpointType::End; - - self.initial_sweep_angle = 0.; - self.initial_start_angle = current_start_angle + rest_angle; - - self.apply_arc_update(node_id, self.initial_start_angle, new_sweep_angle.abs(), input, responses); - } - // Wrap start angle > 180° back into [-180°, 180°] and adjust sweep - () if new_start_angle > 180. => { - let overflow = new_start_angle % 180.; - let rest_angle = angle_delta - overflow; - - // We wrap the angle back into [-180°, 180°] range by jumping from +180° to -180° - // Example: dragging past 190° becomes -170°, and we subtract the overshoot from sweep - // Sweep angle must shrink to maintain consistent arc - self.total_angle_delta = rest_angle; - self.initial_start_angle = -180.; - self.initial_sweep_angle = current_sweep_angle - rest_angle; - - self.apply_arc_update(node_id, self.initial_start_angle + overflow, self.initial_sweep_angle - overflow, input, responses); - } - // Wrap start angle < -180° back into [-180°, 180°] and adjust sweep - () if new_start_angle < -180. => { - let underflow = new_start_angle % 180.; - let rest_angle = angle_delta - underflow; - - // We wrap the angle back into [-180°, 180°] by jumping from -190° to +170° - // Sweep must grow to reflect continued clockwise drag past -180° - // Start angle flips from -190° to +170°, and sweep increases accordingly - self.total_angle_delta = underflow; - self.initial_start_angle = 180.; - self.initial_sweep_angle = current_sweep_angle + rest_angle.abs(); - - self.apply_arc_update(node_id, self.initial_start_angle + underflow, self.initial_sweep_angle + underflow.abs(), input, responses); - } - _ => { - if let Some(snapped_delta) = self.check_snapping(self.initial_sweep_angle + total.abs() * sign) { - total += snapped_delta; - self.update_state(SweepAngleGizmoState::Snapped); - } - - self.total_angle_delta = angle; - self.apply_arc_update(node_id, self.initial_start_angle + total, self.initial_sweep_angle + total.abs() * sign, input, responses); - } - } - } - EndpointType::End => { - // Dragging the end only changes sweep angle - - let mut total = angle; - let new_sweep_angle = self.initial_sweep_angle + angle; - - match () { - // Clamp sweep angle below 0°, switch to start - () if new_sweep_angle < 0. => { - let delta = angle_delta - current_sweep_angle; - let sign = -delta.signum(); - - self.initial_sweep_angle = 0.; - self.total_angle_delta = delta; - self.endpoint = EndpointType::Start; - - self.apply_arc_update(node_id, self.initial_start_angle + delta, self.initial_sweep_angle + delta.abs() * sign, input, responses); - } - // Clamp sweep angle above 360°, switch to start - () if new_sweep_angle > 360. => { - let delta = angle_delta - (360. - new_sweep_angle); - let sign = -delta.signum(); - - self.total_angle_delta = angle_delta - (360. - new_sweep_angle); - self.initial_sweep_angle = 360.; - self.endpoint = EndpointType::Start; - self.update_state(SweepAngleGizmoState::Snapped); - - self.apply_arc_update(node_id, self.initial_start_angle + angle_delta, self.initial_sweep_angle + angle_delta.abs() * sign, input, responses); - } - _ => { - if let Some(snapped_delta) = self.check_snapping(self.initial_sweep_angle + angle) { - total += snapped_delta; - self.update_state(SweepAngleGizmoState::Snapped); - } - - self.total_angle_delta = angle; - self.apply_arc_update(node_id, self.initial_start_angle, self.initial_sweep_angle + total, input, responses); - } - } - } - EndpointType::None => {} - } - } - - /// Applies the updated start and sweep angles to the arc. - fn apply_arc_update(&mut self, node_id: NodeId, start_angle: f64, sweep_angle: f64, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - self.snap_angles = Self::calculate_snap_angles(); - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, graphene_std::vector::generator_nodes::arc::StartAngleInput), - input: NodeInput::value(TaggedValue::F64(start_angle), false), - }); - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, graphene_std::vector::generator_nodes::arc::SweepAngleInput), - input: NodeInput::value(TaggedValue::F64(sweep_angle), false), - }); - - self.previous_mouse_position = input.mouse.position; - responses.add(NodeGraphMessage::RunDocumentGraph); - } - - pub fn check_snapping(&self, new_sweep_angle: f64) -> Option { - self.snap_angles.iter().find(|angle| (**angle - new_sweep_angle).abs() <= ARC_SNAP_THRESHOLD).map(|angle| { - let delta = angle - new_sweep_angle; - if self.endpoint == EndpointType::End { delta } else { -delta } - }) - } - - pub fn calculate_snap_angles() -> Vec { - let mut snap_points = Vec::new(); - - for i in 0..=8 { - let snap_point = i as f64 * FRAC_PI_4; - snap_points.push(snap_point.to_degrees()); - } - - snap_points - } - - pub fn cleanup(&mut self) { - self.layer = None; - self.endpoint = EndpointType::None; - self.handle_state = SweepAngleGizmoState::Inactive; - } -} diff --git a/editor/src/messages/tool/common_functionality/shapes/arc_shape.rs b/editor/src/messages/tool/common_functionality/shapes/arc_shape.rs index e1c62a659c..65268d104d 100644 --- a/editor/src/messages/tool/common_functionality/shapes/arc_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/arc_shape.rs @@ -3,129 +3,13 @@ use super::*; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::circle_arc_radius_handle::{RadiusHandle, RadiusHandleState}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::sweep_angle_gizmo::{SweepAngleGizmo, SweepAngleGizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, arc_outline}; use crate::messages::tool::tool_messages::tool_prelude::*; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use graphene_std::vector::misc::ArcType; use std::collections::VecDeque; -#[derive(Clone, Debug, Default)] -pub struct ArcGizmoHandler { - sweep_angle_gizmo: SweepAngleGizmo, - arc_radius_handle: RadiusHandle, -} - -impl ArcGizmoHandler { - pub fn new() -> Self { - Self { ..Default::default() } - } -} - -impl ShapeGizmoHandler for ArcGizmoHandler { - fn handle_state(&mut self, selected_shape_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { - self.sweep_angle_gizmo.handle_actions(selected_shape_layer, document, mouse_position); - self.arc_radius_handle.handle_actions(selected_shape_layer, document, mouse_position, responses); - } - - fn is_any_gizmo_hovered(&self) -> bool { - self.sweep_angle_gizmo.hovered() || self.arc_radius_handle.hovered() - } - - fn handle_click(&mut self) { - // If hovering over both the gizmos give priority to sweep angle gizmo - if self.sweep_angle_gizmo.hovered() && self.arc_radius_handle.hovered() { - self.sweep_angle_gizmo.update_state(SweepAngleGizmoState::Dragging); - self.arc_radius_handle.update_state(RadiusHandleState::Inactive); - return; - } - - if self.sweep_angle_gizmo.hovered() { - self.sweep_angle_gizmo.update_state(SweepAngleGizmoState::Dragging); - } - - if self.arc_radius_handle.hovered() { - self.arc_radius_handle.update_state(RadiusHandleState::Dragging); - } - } - - fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.sweep_angle_gizmo.is_dragging_or_snapped() { - self.sweep_angle_gizmo.update_arc(document, input, responses); - } - - if self.arc_radius_handle.is_dragging() { - self.arc_radius_handle.update_inner_radius(document, input, responses, drag_start); - } - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut crate::messages::tool::common_functionality::shape_editor::ShapeState, - mouse_position: DVec2, - overlay_context: &mut crate::messages::portfolio::document::overlays::utility_types::OverlayContext, - ) { - if self.sweep_angle_gizmo.is_dragging_or_snapped() { - self.sweep_angle_gizmo.overlays(None, document, input, mouse_position, overlay_context); - arc_outline(self.sweep_angle_gizmo.layer, document, overlay_context); - } - - if self.arc_radius_handle.is_dragging() { - self.sweep_angle_gizmo.overlays(self.arc_radius_handle.layer, document, input, mouse_position, overlay_context); - self.arc_radius_handle.overlays(document, overlay_context); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - selected_shape_layer: Option, - input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut crate::messages::tool::common_functionality::shape_editor::ShapeState, - mouse_position: DVec2, - overlay_context: &mut crate::messages::portfolio::document::overlays::utility_types::OverlayContext, - ) { - // If hovering over both the gizmos give priority to sweep angle gizmo - if self.sweep_angle_gizmo.hovered() && self.arc_radius_handle.hovered() { - self.sweep_angle_gizmo.overlays(selected_shape_layer, document, input, mouse_position, overlay_context); - return; - } - - if self.arc_radius_handle.hovered() { - let layer = self.arc_radius_handle.layer; - - self.arc_radius_handle.overlays(document, overlay_context); - self.sweep_angle_gizmo.overlays(layer, document, input, mouse_position, overlay_context); - } - - self.sweep_angle_gizmo.overlays(selected_shape_layer, document, input, mouse_position, overlay_context); - self.arc_radius_handle.overlays(document, overlay_context); - - arc_outline(selected_shape_layer.or(self.sweep_angle_gizmo.layer), document, overlay_context); - } - - fn mouse_cursor_icon(&self) -> Option { - if self.sweep_angle_gizmo.hovered() || self.sweep_angle_gizmo.is_dragging_or_snapped() { - return Some(MouseCursorIcon::Default); - } - - if self.arc_radius_handle.hovered() || self.arc_radius_handle.is_dragging() { - return Some(MouseCursorIcon::EWResize); - } - - None - } - - fn cleanup(&mut self) { - self.sweep_angle_gizmo.cleanup(); - self.arc_radius_handle.cleanup(); - } -} #[derive(Default)] pub struct Arc; From ed4811d1e78e2bc2b548e2a512caebdfa99c4f5e Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:23:23 +0530 Subject: [PATCH 15/24] Migrate the Grid gizmos to the generic registry system A grid's rows and columns are not grabbed at a point: any spot along an edge will do, and the band the edge occupies counts too. So a shape can now supply `hover_distances` in place of measuring to its handle positions, which is the same escape hatch the other hooks are -- the generic layer keeps arbitrating between overlapping gizmos, it just no longer assumes the thing being aimed at is a point. Dragging the top edge upward also has to move the layer. The node builds its grid downward from the origin, so without a matching translation the new rows would appear at the bottom and the edge would slide out from under the cursor. `drag` therefore returns a `DragWrites` rather than a bare list of inputs, carrying an optional transform alongside them. Dragging past the last row does not stop at one either -- the grid turns inside out and the opposite edge takes over -- so the gesture re-anchors its origin the same way the arc re-anchors its baseline. Rows and columns are declared as dials, but a declaration that brings its own drag is hosted by the general handle gizmo rather than the dial: the dial is the narrow one, a count stepped by horizontal drag, and none of that applies here. The grid's edge geometry -- four edges across two layouts, rectangular and isometric -- moves to `grid_shape.rs`. It is the grid's own geometry rather than gizmo machinery, and it is the same shape of split as the star's snap radii. The spacing gizmo the registry used to declare is dropped. It was a position gizmo that was never built, and spacing is a two-axis value with no obvious handle on the shape. With this the `shape_gizmos` module is empty and deleted. All seven shapes now run on the registry. --- .../generic_gizmos/generic_slider_gizmo.rs | 52 ++- .../gizmos/generic_gizmos/mod.rs | 27 +- .../gizmos/gizmo_behaviors.rs | 189 +++++++- .../gizmos/gizmo_manager.rs | 14 +- .../gizmos/gizmo_registry.rs | 48 +- .../tool/common_functionality/gizmos/mod.rs | 1 - .../shape_gizmos/grid_rows_columns_gizmo.rs | 435 ------------------ .../gizmos/shape_gizmos/mod.rs | 1 - .../common_functionality/shapes/grid_shape.rs | 355 +++++++++++--- 9 files changed, 564 insertions(+), 558 deletions(-) delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/grid_rows_columns_gizmo.rs delete mode 100644 editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 8b92c77ab0..c5a610ce93 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -8,16 +8,18 @@ use crate::consts::{GIZMO_HIDE_THRESHOLD, POINT_RADIUS_HANDLE_SNAP_THRESHOLD}; use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::message::Message; +use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::prelude::GraphOperationMessage; use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses}; -use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_f64_input; +use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_number_input; use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, GizmoContext, GizmoInfo, GizmoState, PositionHint}; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use glam::DVec2; +use glam::{DAffine2, DVec2}; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::NodeId; use graph_craft::document::NodeInput; @@ -62,6 +64,9 @@ pub struct GenericSliderGizmo { total_angle: f64, /// This frame's rotation about the layer origin, in degrees. angle_delta: f64, + /// Where the gesture is currently measured from. Normally where the drag began, but a shape that + /// re-anchors mid-drag moves it. + drag_origin: Option, } impl GenericSliderGizmo { @@ -79,6 +84,7 @@ impl GenericSliderGizmo { previous_mouse_position: DVec2::ZERO, total_angle: 0., angle_delta: 0., + drag_origin: None, } } @@ -95,6 +101,7 @@ impl GenericSliderGizmo { self.snap_targets.clear(); self.initial_parameters.clear(); self.total_angle = 0.; + self.drag_origin = None; } /// Begin a drag if currently hovered. @@ -139,7 +146,7 @@ impl GenericSliderGizmo { } fn current_value(&self, document: &DocumentMessageHandler) -> Option { - read_f64_input(self.layer, document, &self.identifier, self.info.parameter_index) + read_number_input(self.layer, document, &self.identifier, self.info.parameter_index) } /// Every point in the layer's local space where this parameter can be grabbed. @@ -174,24 +181,38 @@ impl GenericSliderGizmo { let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); + self.hover_distances(document, value, mouse_position, viewport, center) + .into_iter() + .flatten() + .filter(|distance| *distance <= SLIDER_HANDLE_HOVER_THRESHOLD) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + } + + /// Distance from the cursor to each grab point, or `None` for grab points that are unavailable or too + /// small on screen to aim at. + fn hover_distances(&self, document: &DocumentMessageHandler, value: f64, mouse_position: DVec2, viewport: DAffine2, center: DVec2) -> Vec> { + if let Some(distances) = self.info.behavior.hover_distances { + return distances(&self.context(document, mouse_position, None)); + } + self.handle_positions(document, value) .into_iter() .map(|local| viewport.transform_point2(local)) // Hide the gizmo when the shape is too small on screen to interact with reliably. - .filter(|handle| handle.distance(center) >= GIZMO_HIDE_THRESHOLD) - .map(|handle| mouse_position.distance(handle)) - .filter(|distance| *distance <= SLIDER_HANDLE_HOVER_THRESHOLD) - .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|handle| (handle.distance(center) >= GIZMO_HIDE_THRESHOLD).then(|| mouse_position.distance(handle))) + .collect() } /// Index of the grab point nearest the cursor, so a drag knows which ray it runs along. fn nearest_handle_index(&self, document: &DocumentMessageHandler, value: f64, mouse_position: DVec2) -> usize { let viewport = document.metadata().transform_to_viewport(self.layer); - self.handle_positions(document, value) + let center = viewport.transform_point2(DVec2::ZERO); + + self.hover_distances(document, value, mouse_position, viewport, center) .into_iter() - .map(|local| mouse_position.distance(viewport.transform_point2(local))) .enumerate() + .filter_map(|(index, distance)| distance.map(|distance| (index, distance))) .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|(index, _)| index) .unwrap_or(0) @@ -235,7 +256,7 @@ impl GenericSliderGizmo { if let Some(drag) = self.info.behavior.drag { let mut drag_input = DragInput { - drag_start, + drag_start: self.drag_origin.unwrap_or(drag_start), mouse_position: input.mouse.position, initial_value: self.initial_value, initial_parameters: self.initial_parameters.clone(), @@ -251,16 +272,25 @@ impl GenericSliderGizmo { self.initial_parameters = drag_input.initial_parameters; self.handle_index = drag_input.handle_index; self.initial_value = drag_input.initial_value; + self.drag_origin = Some(drag_input.drag_start); if writes.is_empty() { return; } - for (parameter, value) in writes { + for (parameter, value) in writes.inputs { responses.add(NodeGraphMessage::SetInput { input_connector: InputConnector::node(self.node_id, parameter), input: NodeInput::value(value, false), }); } + if let Some(transform) = writes.transform { + responses.add(GraphOperationMessage::TransformChange { + layer: self.layer, + transform, + transform_in: TransformIn::Viewport, + skip_rerender: false, + }); + } responses.add(NodeGraphMessage::RunDocumentGraph); return; } diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs index c3ce1e0c31..84babcf988 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -42,6 +42,19 @@ pub fn read_f64_input(layer: LayerNodeIdentifier, document: &DocumentMessageHand } } +/// Read a node input as a number, whichever numeric type it is stored as. +/// +/// The generic gizmos use this for hit-testing and overlays, where all that matters is how large the value +/// is. Writing still goes through the parameter's own type, so a count stays a count. +pub fn read_number_input(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, identifier: &ProtoNodeIdentifier, index: usize) -> Option { + let inputs = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(identifier.clone()))?; + match inputs.get(index)?.as_value()? { + TaggedValue::F64(value) => Some(*value), + TaggedValue::U32(value) => Some(*value as f64), + _ => None, + } +} + /// Read a `u32` node input value by node identifier and parameter index. pub fn read_u32_input(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, identifier: &ProtoNodeIdentifier, index: usize) -> Option { let inputs = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(identifier.clone()))?; @@ -156,11 +169,17 @@ impl GenericGizmoManager { let mut gizmos = Vec::new(); for info in infos { + // `GenericSliderGizmo` is the general hook-driven handle: it carries the grab points, the + // hover test, and the drag, any of which a shape can replace. `GenericDialGizmo` is the + // narrower one, a count stepped by horizontal drag with no hooks of its own. So a + // declaration that brings its own drag is hosted by the general one whatever it declares, + // and only a dial relying on the default gets the dial. + let brings_own_drag = info.behavior.drag.is_some(); + match info.gizmo_type { - // An angle runs on the same handle machinery as a slider; what differs is the drag, which - // its registry entry supplies. - GizmoType::Slider | GizmoType::Angle => gizmos.push(GenericGizmo::Slider(GenericSliderGizmo::new(layer, node_id, identifier.clone(), *info))), - GizmoType::Dial => gizmos.push(GenericGizmo::Dial(GenericDialGizmo::new(layer, node_id, identifier.clone(), *info))), + GizmoType::Dial if !brings_own_drag => gizmos.push(GenericGizmo::Dial(GenericDialGizmo::new(layer, node_id, identifier.clone(), *info))), + // An angle runs on the same handle machinery as a length; what differs is the drag. + GizmoType::Slider | GizmoType::Angle | GizmoType::Dial => gizmos.push(GenericGizmo::Slider(GenericSliderGizmo::new(layer, node_id, identifier.clone(), *info))), // Position gizmos are not yet implemented; they are skipped so a partially-migrated node // still gets its other controls. GizmoType::Position => {} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 5e5a5946b4..1fa2bc5b04 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -13,11 +13,12 @@ use crate::consts::{ARC_SNAP_THRESHOLD, COLOR_OVERLAY_RED}; use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, GizmoBehavior, GizmoContext, GizmoState}; +use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, DragWrites, GizmoBehavior, GizmoContext, GizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::shapes::grid_shape::RowColumnGizmoType; use crate::messages::tool::common_functionality::shapes::shape_utility::{ - arc_end_points, arc_end_points_ignore_layer, arc_outline, calculate_arc_text_transform, draw_snapping_ticks, extract_arc_parameters, extract_polygon_parameters, extract_spiral_parameters, - extract_star_parameters, format_rounded, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, + arc_end_points, arc_end_points_ignore_layer, arc_outline, calculate_arc_text_transform, draw_snapping_ticks, extract_arc_parameters, extract_grid_parameters, extract_polygon_parameters, + extract_spiral_parameters, extract_star_parameters, format_rounded, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, }; use crate::messages::tool::common_functionality::shapes::spiral_shape::calculate_spiral_endpoints; use glam::{DAffine2, DVec2}; @@ -26,7 +27,8 @@ use graphene_std::NodeParameter; use graphene_std::ParameterRef; use graphene_std::vector::algorithms::shapes::{calculate_growth_factor, spiral_point}; use graphene_std::vector::generator_nodes::star; -use graphene_std::vector::misc::SpiralType; +use graphene_std::vector::misc::{GridType, SpiralType, dvec2_to_point, get_line_endpoints}; +use kurbo::ParamCurveNearest; use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_4, PI, SQRT_2, TAU}; /// The star's sides dial: previews the shape it is about to change. @@ -35,6 +37,7 @@ pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { overlay: Some(star_sides_overlay), coupled_writes: None, handle_positions: None, + hover_distances: None, drag: None, angle_deadzone: 0., }; @@ -46,10 +49,33 @@ pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { overlay: Some(star_radius_overlay), coupled_writes: None, handle_positions: Some(star_radius_handles), + hover_distances: None, drag: None, angle_deadzone: 0., }; +/// The grid's row count, grabbed along its top or bottom edge. +pub const GRID_ROWS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(grid_edge_overlay), + coupled_writes: None, + handle_positions: None, + hover_distances: Some(grid_row_distances), + drag: Some(grid_edge_drag), + angle_deadzone: 0., +}; + +/// The grid's column count, grabbed along its left or right edge. +pub const GRID_COLUMNS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(grid_edge_overlay), + coupled_writes: None, + handle_positions: None, + hover_distances: Some(grid_column_distances), + drag: Some(grid_edge_drag), + angle_deadzone: 0., +}; + /// The arc's sweep, grabbable at either end of the curve. Dragging either endpoint reshapes the arc; the /// start endpoint carries the whole arc round with it, the end endpoint only opens or closes the sweep. pub const ARC_SWEEP: GizmoBehavior = GizmoBehavior { @@ -57,6 +83,7 @@ pub const ARC_SWEEP: GizmoBehavior = GizmoBehavior { overlay: Some(arc_sweep_overlay), coupled_writes: None, handle_positions: Some(arc_sweep_handles), + hover_distances: None, drag: Some(arc_sweep_drag), angle_deadzone: 0., }; @@ -67,6 +94,7 @@ pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { overlay: Some(spiral_turns_overlay), coupled_writes: None, handle_positions: Some(spiral_turns_handles), + hover_distances: None, drag: Some(spiral_turns_drag), angle_deadzone: 0.5, }; @@ -77,6 +105,7 @@ pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { overlay: Some(polygon_sides_overlay), coupled_writes: None, handle_positions: None, + hover_distances: None, drag: None, angle_deadzone: 0., }; @@ -311,7 +340,7 @@ fn spiral_turns_handles(context: &GizmoContext, _value: f64) -> Vec { /// Turns alone would change the spiral's tightness as it grows, so the outer radius moves with it by /// whatever keeps the growth factor the drag started with. Taking hold of the inner end winds the other /// way and carries the start angle along, so the end the user is *not* holding stays put. -fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(ParameterRef, TaggedValue)> { +fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { use graphene_std::vector::generator_nodes::spiral::*; let (Some(initial_turns), Some(initial_outer_radius), Some(initial_inner_radius), Some(initial_start_angle)) = ( @@ -320,10 +349,10 @@ fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(Param initial_f64(drag, InnerRadiusInput::INDEX), initial_f64(drag, StartAngleInput::INDEX), ) else { - return Vec::new(); + return DragWrites::default(); }; let Some((spiral_type, ..)) = extract_spiral_parameters(context.layer, context.document) else { - return Vec::new(); + return DragWrites::default(); }; let growth_factor = calculate_growth_factor(initial_inner_radius, initial_turns, initial_outer_radius, spiral_type); @@ -334,7 +363,7 @@ fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(Param SpiralType::Logarithmic => initial_outer_radius * ((growth_factor * TAU * turns_delta).exp() - 1.), }; if !outer_radius_change.is_finite() { - return Vec::new(); + return DragWrites::default(); } // Handle 0 is the inner end of the curve; dragging it winds the spiral in the opposite direction. @@ -350,7 +379,7 @@ fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(Param writes.push((StartAngleInput.into(), TaggedValue::F64(initial_start_angle + spiral_swept_angle(drag)))); } - writes + DragWrites::inputs(writes) } /// Mark both ends of the spiral at rest, and the end being held once one is grabbed. @@ -411,21 +440,21 @@ fn arc_snap_delta(sweep_angle: f64, dragging_start: bool) -> Option { /// [-180°, 180°]. Both limits are reached by *continuing* a drag rather than ending it, so rather than /// stopping at the limit the gesture re-anchors: dragging the start endpoint past a full sweep hands over /// to the end endpoint and carries on from there, which is why the baseline is rewritten as it goes. -fn arc_sweep_drag(context: &GizmoContext, drag: &mut DragInput) -> Vec<(ParameterRef, TaggedValue)> { +fn arc_sweep_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { use graphene_std::vector::generator_nodes::arc::*; let Some((_, current_start_angle, current_sweep_angle, _)) = extract_arc_parameters(Some(context.layer), context.document) else { - return Vec::new(); + return DragWrites::default(); }; let (Some(initial_start_angle), Some(initial_sweep_angle)) = (initial_f64(drag, StartAngleInput::INDEX), initial_f64(drag, SweepAngleInput::INDEX)) else { - return Vec::new(); + return DragWrites::default(); }; let angle_delta = drag.angle_delta; let angle = drag.total_angle; let dragging_start = drag.handle_index == 0; - let write = |start: f64, sweep: f64| vec![(StartAngleInput.into(), TaggedValue::F64(start)), (SweepAngleInput.into(), TaggedValue::F64(sweep))]; + let write = |start: f64, sweep: f64| DragWrites::inputs(vec![(StartAngleInput.into(), TaggedValue::F64(start)), (SweepAngleInput.into(), TaggedValue::F64(sweep))]); if dragging_start { // The start endpoint drags the whole arc round, so the sweep closes by as much as the start opens. @@ -559,3 +588,137 @@ fn arc_sweep_overlay(context: &GizmoContext, overlay_context: &mut OverlayContex overlay_context.arc_sweep_angle(offset_angle, angle, point, point.distance(center), center, &text, transform); } + +/// Squared viewport distance within which an edge counts as grabbed, matching the hand-written gizmo. +const GRID_EDGE_THRESHOLD_SQUARED: f64 = 32.; + +/// The two edges that control a grid's rows, in the order their handle indices refer to. +const GRID_ROW_EDGES: [RowColumnGizmoType; 2] = [RowColumnGizmoType::Top, RowColumnGizmoType::Bottom]; +/// The two edges that control a grid's columns. +const GRID_COLUMN_EDGES: [RowColumnGizmoType; 2] = [RowColumnGizmoType::Left, RowColumnGizmoType::Right]; + +fn grid_row_distances(context: &GizmoContext) -> Vec> { + grid_edge_distances(context, GRID_ROW_EDGES) +} + +fn grid_column_distances(context: &GizmoContext) -> Vec> { + grid_edge_distances(context, GRID_COLUMN_EDGES) +} + +/// A grid's dimensions are grabbed anywhere along an edge, not at a point on it, so proximity is measured to +/// the edge line -- or to nothing at all, if the cursor is inside the band the edge occupies. +fn grid_edge_distances(context: &GizmoContext, edges: [RowColumnGizmoType; 2]) -> Vec> { + let Some((grid_type, spacing, columns, rows, angles)) = extract_grid_parameters(context.layer, context.document) else { + return vec![None, None]; + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let mouse_point = dvec2_to_point(context.mouse_position); + + edges + .into_iter() + .map(|edge| { + if edge.rect(grid_type, columns, rows, spacing, angles, viewport).contains(mouse_point) { + return Some(0.); + } + let distance_squared = edge.line(grid_type, columns, rows, spacing, angles, viewport).nearest(mouse_point, 1e-6).distance_sq; + + (distance_squared < GRID_EDGE_THRESHOLD_SQUARED).then(|| distance_squared.sqrt()) + }) + .collect() +} + +fn grid_edges(context: &GizmoContext) -> [RowColumnGizmoType; 2] { + use graphene_std::vector::generator_nodes::grid; + + if context.parameter == ParameterRef::from(grid::ColumnsInput) { + GRID_COLUMN_EDGES + } else { + GRID_ROW_EDGES + } +} + +/// Add or remove rows and columns by dragging an edge. +/// +/// The grid also has to move as it resizes. Dragging the top edge upward adds rows, but the node builds its +/// grid downward from the origin, so without a matching translation the new rows would appear at the bottom +/// and the edge would slide out from under the cursor. +/// +/// Dragging an edge past the last row or column does not stop at one: the grid turns inside out and the +/// opposite edge takes over, which is why the gesture re-anchors rather than clamping. +fn grid_edge_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { + let Some((grid_type, spacing, columns, rows, angles)) = extract_grid_parameters(context.layer, context.document) else { + return DragWrites::default(); + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let edge = grid_edges(context)[drag.handle_index.min(1)]; + + let direction = edge.direction(viewport); + let delta_vector = drag.mouse_position - drag.drag_start; + let projection = delta_vector.project_onto(direction); + let delta = viewport.inverse().transform_vector2(projection).length() * delta_vector.dot(direction).signum(); + + if delta.abs() < 1e-6 { + return DragWrites::default(); + } + + let initial_dimension = match initial_u32(drag, edge.parameter().input_index) { + Some(dimension) => dimension as i32, + None => return DragWrites::default(), + }; + let dimensions_to_add = (delta / edge.spacing(spacing, grid_type, angles)).floor() as i32; + let new_dimension = (initial_dimension + dimensions_to_add).max(1) as u32; + let dimensions_delta = new_dimension as i32 - edge.initial_dimension(rows, columns) as i32; + + let mut writes = DragWrites { + inputs: vec![(edge.parameter(), TaggedValue::U32(new_dimension))], + transform: Some(grid_edge_transform(edge, dimensions_delta, spacing, grid_type, angles, viewport)), + }; + + // Dragged past the last row or column: flip to the opposite edge and start counting again from one. + if initial_dimension + dimensions_to_add < 1 { + drag.drag_start = drag.mouse_position; + drag.handle_index = 1 - drag.handle_index.min(1); + set_initial_u32(drag, edge.parameter().input_index, 1); + writes.inputs = vec![(edge.parameter(), TaggedValue::U32(1))]; + } + + writes +} + +/// Only the top and left edges move the layer: the grid is built rightward and downward from its origin, so +/// growing from the other two edges already puts the new cells where the cursor is. +fn grid_edge_transform(edge: RowColumnGizmoType, dimensions_delta: i32, spacing: DVec2, grid_type: GridType, angles: DVec2, viewport: DAffine2) -> DAffine2 { + match edge { + RowColumnGizmoType::Top => DAffine2::from_translation(edge.direction(viewport) * dimensions_delta as f64 * spacing.y), + RowColumnGizmoType::Left => DAffine2::from_translation(edge.direction(viewport) * dimensions_delta as f64 * edge.spacing(spacing, grid_type, angles)), + _ => DAffine2::IDENTITY, + } +} + +/// Mark the edge in play with a dashed line along it. +fn grid_edge_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + if context.state == GizmoState::Inactive { + return; + } + let Some((grid_type, spacing, columns, rows, angles)) = extract_grid_parameters(context.layer, context.document) else { + return; + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let edge = grid_edges(context)[context.handle_index.min(1)]; + + let (p0, p1) = get_line_endpoints(edge.line(grid_type, columns, rows, spacing, angles, viewport)); + overlay_context.dashed_line(p0, p1, None, None, Some(5.), Some(5.), Some(0.5)); +} + +fn initial_u32(drag: &DragInput, index: usize) -> Option { + match drag.initial_parameters.get(index)? { + Some(TaggedValue::U32(value)) => Some(*value), + _ => None, + } +} + +fn set_initial_u32(drag: &mut DragInput, index: usize, value: u32) { + if let Some(slot) = drag.initial_parameters.get_mut(index) { + *slot = Some(TaggedValue::U32(value)); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index eeae863ea8..f5fd7744fb 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -6,7 +6,6 @@ use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageH use crate::messages::tool::common_functionality::gizmos::generic_gizmos::GenericGizmoManager; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use glam::DVec2; use std::collections::VecDeque; @@ -23,7 +22,6 @@ use std::collections::VecDeque; pub enum ShapeGizmoHandlers { #[default] None, - Grid(GridGizmoHandler), /// Registry-driven generic handler. Used for nodes that declare their gizmos in the /// [gizmo registry](super::gizmo_registry) rather than via a hand-written handler. Generic(GenericGizmoManager), @@ -34,7 +32,6 @@ impl ShapeGizmoHandlers { /// Used for grouping logic and distinguishing between handler types at runtime. pub fn kind(&self) -> &'static str { match self { - Self::Grid(_) => "grid", Self::Generic(_) => "generic", Self::None => "none", } @@ -43,7 +40,6 @@ impl ShapeGizmoHandlers { /// Dispatches interaction state updates to the corresponding shape-specific handler. pub fn handle_state(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, responses: &mut VecDeque) { match self { - Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } @@ -52,7 +48,6 @@ impl ShapeGizmoHandlers { /// Checks if any interactive part of the gizmo is currently hovered. pub fn is_any_gizmo_hovered(&self) -> bool { match self { - Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } @@ -61,7 +56,6 @@ impl ShapeGizmoHandlers { /// Passes the click interaction to the appropriate gizmo handler if one is hovered. pub fn handle_click(&mut self) { match self { - Self::Grid(h) => h.handle_click(), Self::Generic(h) => h.handle_click(), Self::None => {} } @@ -70,7 +64,6 @@ impl ShapeGizmoHandlers { /// Updates the gizmo state while the user is dragging a handle (e.g., adjusting radius). pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { match self { - Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } @@ -79,7 +72,6 @@ impl ShapeGizmoHandlers { /// Cleans up any state used by the gizmo handler. pub fn cleanup(&mut self) { match self { - Self::Grid(h) => h.cleanup(), Self::Generic(h) => h.cleanup(), Self::None => {} } @@ -96,7 +88,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -112,7 +103,6 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { match self { - Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } @@ -120,7 +110,6 @@ impl ShapeGizmoHandlers { pub fn gizmo_cursor_icon(&self) -> Option { match self { - Self::Grid(h) => h.mouse_cursor_icon(), Self::Generic(h) => h.mouse_cursor_icon(), Self::None => None, } @@ -168,8 +157,9 @@ impl GizmoManager { return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Grid + // Grid — migrated to the generic, registry-driven gizmo system (rows and columns by edge). if graph_modification_utils::get_grid_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Grid(GridGizmoHandler::default())); + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } // Spiral // Spiral — migrated to the generic, registry-driven gizmo system (winding from either endpoint). diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index 61d0a61351..6cb6f482de 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -17,7 +17,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::prelude::DocumentMessageHandler; use crate::messages::tool::common_functionality::gizmos::gizmo_behaviors; use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use glam::DVec2; +use glam::{DAffine2, DVec2}; use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::TaggedValue; use graphene_std::vector::generator_nodes; @@ -105,6 +105,28 @@ pub struct DragInput { pub handle_index: usize, } +/// What a drag wants done, once it has worked out what the cursor meant. +#[derive(Default)] +pub struct DragWrites { + /// Node inputs to set. + pub inputs: Vec<(ParameterRef, TaggedValue)>, + /// A transform to apply to the layer, for a control that repositions the shape as it resizes it. A grid + /// grown from its top edge has to move up as it gains a row, or it would grow downward instead and the + /// edge would run away from the cursor holding it. + pub transform: Option, +} + +impl DragWrites { + /// The common case: a drag that only writes node inputs. + pub fn inputs(inputs: Vec<(ParameterRef, TaggedValue)>) -> Self { + Self { inputs, transform: None } + } + + pub fn is_empty(&self) -> bool { + self.inputs.is_empty() && self.transform.is_none() + } +} + /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. /// /// The generic layer always owns hit-testing, the hover/drag state machine, the handle overlay, and the @@ -130,6 +152,12 @@ pub struct GizmoBehavior { /// a star's outer radius can be taken hold of at any of its outer points — so this returns all of them. /// The drag then runs along the ray through whichever one the user grabbed, not along a fixed axis. pub handle_positions: Option Vec>, + /// How far the cursor is from each of this parameter's grab points, when they are not points. + /// + /// The default measures to the handle positions above, which suits a control you take hold of at a + /// spot. A grid's rows are grabbed anywhere along an edge, so it measures to a line instead. `None` in + /// a slot means that grab point is not available right now. + pub hover_distances: Option Vec>>, /// How cursor motion becomes node inputs. /// /// The default reads the cursor's distance along the ray through the grabbed handle and writes the one @@ -143,7 +171,7 @@ pub struct GizmoBehavior { /// The [`DragInput`] is mutable because a drag may have to re-anchor itself: an arc dragged past a full /// sweep hands over to its other endpoint and continues from there, which means rewriting the baseline /// the rest of the gesture is measured against. - pub drag: Option Vec<(ParameterRef, TaggedValue)>>, + pub drag: Option DragWrites>, /// Per-frame rotation, in degrees, below which swept angle is treated as cursor noise rather than /// intent. Near the layer's origin the angle between successive cursor positions is mostly noise, and /// feeding it in makes the value jitter while the cursor is still. Zero accumulates everything. @@ -157,6 +185,7 @@ impl GizmoBehavior { overlay: None, coupled_writes: None, handle_positions: None, + hover_distances: None, drag: None, angle_deadzone: 0., }; @@ -284,6 +313,8 @@ const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { position_hint: PositionHint::ParameterDerived, }]; +// Rows and columns only. The spacing was declared as a position gizmo that was never built, and a grid's +// spacing is a two-axis value with no obvious handle on the shape -- it stays in the Properties panel. const GRID_GIZMOS: &[GizmoInfo] = &[ GizmoInfo { parameter_index: grid::ColumnsInput::INDEX, @@ -291,7 +322,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ name: "Columns", min: Some(1.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::GRID_COLUMNS, position_hint: PositionHint::BoundingBoxCorner, }, GizmoInfo { @@ -300,16 +331,7 @@ const GRID_GIZMOS: &[GizmoInfo] = &[ name: "Rows", min: Some(1.), max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::BoundingBoxCorner, - }, - GizmoInfo { - parameter_index: grid::SpacingInput::INDEX, - gizmo_type: GizmoType::Position, - name: "Spacing", - min: Some(0.), - max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::GRID_ROWS, position_hint: PositionHint::BoundingBoxCorner, }, ]; diff --git a/editor/src/messages/tool/common_functionality/gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/mod.rs index 9e72fe329d..01bdcfcb37 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/mod.rs @@ -2,4 +2,3 @@ pub mod generic_gizmos; pub mod gizmo_behaviors; pub mod gizmo_manager; pub mod gizmo_registry; -pub mod shape_gizmos; diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/grid_rows_columns_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/grid_rows_columns_gizmo.rs deleted file mode 100644 index a5b2a39712..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/grid_rows_columns_gizmo.rs +++ /dev/null @@ -1,435 +0,0 @@ -use crate::consts::GRID_ROW_COLUMN_GIZMO_OFFSET; -use crate::messages::frontend::utility_types::MouseCursorIcon; -use crate::messages::message::Message; -use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler, NodeGraphMessage}; -use crate::messages::prelude::{GraphOperationMessage, Responses}; -use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::extract_grid_parameters; -use glam::{DAffine2, DVec2}; -use graph_craft::document::NodeInput; -use graph_craft::document::value::TaggedValue; -use graphene_std::ParameterRef; -use graphene_std::vector::misc::{GridType, dvec2_to_point, get_line_endpoints}; -use kurbo::{Line, ParamCurveNearest, Rect}; -use std::collections::VecDeque; - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum RowColumnGizmoState { - #[default] - Inactive, - Hover, - Dragging, -} - -#[derive(Clone, Debug, Default)] -pub struct RowColumnGizmo { - pub layer: Option, - pub gizmo_type: RowColumnGizmoType, - initial_rows: u32, - initial_columns: u32, - spacing: DVec2, - initial_mouse_start: Option, - gizmo_state: RowColumnGizmoState, -} - -impl RowColumnGizmo { - pub fn cleanup(&mut self) { - self.layer = None; - self.gizmo_state = RowColumnGizmoState::Inactive; - self.initial_mouse_start = None; - } - - pub fn update_state(&mut self, state: RowColumnGizmoState) { - self.gizmo_state = state; - } - - pub fn is_hovered(&self) -> bool { - self.gizmo_state == RowColumnGizmoState::Hover - } - - pub fn is_dragging(&self) -> bool { - self.gizmo_state == RowColumnGizmoState::Dragging - } - - fn initial_dimension(&self) -> u32 { - match &self.gizmo_type { - RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => self.initial_rows, - RowColumnGizmoType::Left | RowColumnGizmoType::Right => self.initial_columns, - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), - } - } - - pub fn handle_actions(&mut self, layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler) { - let Some((grid_type, spacing, columns, rows, angles)) = extract_grid_parameters(layer, document) else { - return; - }; - let viewport = document.metadata().transform_to_viewport(layer); - - if let Some(gizmo_type) = check_if_over_gizmo(grid_type, columns, rows, spacing, angles, mouse_position, viewport) { - self.layer = Some(layer); - self.gizmo_type = gizmo_type; - self.initial_rows = rows; - self.initial_columns = columns; - self.spacing = spacing; - self.initial_mouse_start = None; - self.update_state(RowColumnGizmoState::Hover); - } - } - - pub fn overlays(&self, document: &DocumentMessageHandler, layer: Option, _shape_editor: &mut &mut ShapeState, _mouse_position: DVec2, overlay_context: &mut OverlayContext) { - let Some(layer) = layer.or(self.layer) else { return }; - let Some((grid_type, spacing, columns, rows, angles)) = extract_grid_parameters(layer, document) else { - return; - }; - let viewport = document.metadata().transform_to_viewport(layer); - - if !matches!(self.gizmo_state, RowColumnGizmoState::Inactive) { - let line = self.gizmo_type.line(grid_type, columns, rows, spacing, angles, viewport); - let (p0, p1) = get_line_endpoints(line); - overlay_context.dashed_line(p0, p1, None, None, Some(5.), Some(5.), Some(0.5)); - } - } - - pub fn update(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, drag_start: DVec2) { - let Some(layer) = self.layer else { return }; - let viewport = document.metadata().transform_to_viewport(layer); - - let Some((grid_type, _, columns, rows, angles)) = extract_grid_parameters(layer, document) else { - return; - }; - let direction = self.gizmo_type.direction(viewport); - let delta_vector = input.mouse.position - self.initial_mouse_start.unwrap_or(drag_start); - - let projection = delta_vector.project_onto(self.gizmo_type.direction(viewport)); - let delta = viewport.inverse().transform_vector2(projection).length() * delta_vector.dot(direction).signum(); - - if delta.abs() < 1e-6 { - return; - } - - let dimensions_to_add = (delta / (self.gizmo_type.spacing(self.spacing, grid_type, angles))).floor() as i32; - let new_dimension = (self.initial_dimension() as i32 + dimensions_to_add).max(1) as u32; - - let Some(node_id) = graph_modification_utils::get_grid_id(layer, &document.network_interface) else { - return; - }; - - let dimensions_delta = new_dimension as i32 - self.gizmo_type.initial_dimension(rows, columns) as i32; - let transform = self.transform_grid(dimensions_delta, self.spacing, grid_type, angles, viewport); - - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, self.gizmo_type.parameter()), - input: NodeInput::value(TaggedValue::U32((self.initial_dimension() as i32 + dimensions_to_add).max(1) as u32), false), - }); - - responses.add(GraphOperationMessage::TransformChange { - layer, - transform, - transform_in: TransformIn::Viewport, - skip_rerender: false, - }); - - responses.add(NodeGraphMessage::RunDocumentGraph); - - if self.initial_dimension() as i32 + dimensions_to_add < 1 { - self.initial_mouse_start = Some(input.mouse.position); - self.gizmo_type = self.gizmo_type.opposite_gizmo_type(); - self.initial_rows = 1; - self.initial_columns = 1; - } - } - - fn transform_grid(&self, dimensions_delta: i32, spacing: DVec2, grid_type: GridType, angles: DVec2, viewport: DAffine2) -> DAffine2 { - match &self.gizmo_type { - RowColumnGizmoType::Top => { - let move_up_by = self.gizmo_type.direction(viewport) * dimensions_delta as f64 * spacing.y; - DAffine2::from_translation(move_up_by) - } - RowColumnGizmoType::Left => { - let move_left_by = self.gizmo_type.direction(viewport) * dimensions_delta as f64 * self.gizmo_type.spacing(spacing, grid_type, angles); - DAffine2::from_translation(move_left_by) - } - RowColumnGizmoType::Bottom | RowColumnGizmoType::Right | RowColumnGizmoType::None => DAffine2::IDENTITY, - } - } -} - -fn check_if_over_gizmo(grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, mouse_position: DVec2, viewport: DAffine2) -> Option { - let mouse_point = dvec2_to_point(mouse_position); - let accuracy = 1e-6; - let threshold = 32.; - - for gizmo_type in RowColumnGizmoType::all() { - let line = gizmo_type.line(grid_type, columns, rows, spacing, angles, viewport); - let rect = gizmo_type.rect(grid_type, columns, rows, spacing, angles, viewport); - - if rect.contains(mouse_point) || line.nearest(mouse_point, accuracy).distance_sq < threshold { - return Some(gizmo_type); - } - } - - None -} - -fn convert_to_gizmo_line(p0: DVec2, p1: DVec2) -> Line { - Line { - p0: dvec2_to_point(p0), - p1: dvec2_to_point(p1), - } -} - -/// Get corners of the rectangular-grid. -/// Returns a tuple of (topleft,topright,bottomright,bottomleft) -fn get_corners(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2, DVec2, DVec2) { - let (width, height) = (spacing.x, spacing.y); - - let x_distance = (columns - 1) as f64 * width; - let y_distance = (rows - 1) as f64 * height; - - let point0 = DVec2::ZERO; - let point1 = DVec2::new(x_distance, 0.); - let point2 = DVec2::new(x_distance, y_distance); - let point3 = DVec2::new(0., y_distance); - - (point0, point1, point2, point3) -} - -fn get_rectangle_top_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { - let (top_left, top_right, _, _) = get_corners(columns, rows, spacing); - let offset = if columns == 1 || rows == 1 { - DVec2::ZERO - } else if columns == 2 { - DVec2::new(spacing.x * 0.25, 0.) - } else { - DVec2::new(spacing.x * 0.5, 0.) - }; - - (top_left + offset, top_right - offset) -} - -fn get_rectangle_bottom_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { - let (_, _, bottom_right, bottom_left) = get_corners(columns, rows, spacing); - let offset = if columns == 1 || rows == 1 { - DVec2::ZERO - } else if columns == 2 { - DVec2::new(spacing.x * 0.25, 0.) - } else { - DVec2::new(spacing.x * 0.5, 0.) - }; - - (bottom_left + offset, bottom_right - offset) -} - -fn get_rectangle_right_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { - let (_, top_right, bottom_right, _) = get_corners(columns, rows, spacing); - let offset = if columns == 1 || rows == 1 { - DVec2::ZERO - } else if rows == 2 { - DVec2::new(0., -spacing.y * 0.25) - } else { - DVec2::new(0., -spacing.y * 0.5) - }; - - (top_right - offset, bottom_right + offset) -} - -fn get_rectangle_left_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { - let (top_left, _, _, bottom_left) = get_corners(columns, rows, spacing); - let offset = if columns == 1 || rows == 1 { - DVec2::ZERO - } else if rows == 2 { - DVec2::new(0., -spacing.y * 0.25) - } else { - DVec2::new(0., -spacing.y * 0.5) - }; - - (top_left - offset, bottom_left + offset) -} - -fn calculate_isometric_point(column: u32, row: u32, angles: DVec2, spacing: DVec2) -> DVec2 { - let tan_a = angles.x.to_radians().tan(); - let tan_b = angles.y.to_radians().tan(); - - let spacing = DVec2::new(spacing.y / (tan_a + tan_b), spacing.y); - - let a_angles_eaten = column.div_ceil(2) as f64; - let b_angles_eaten = (column / 2) as f64; - - let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a; - - DVec2::new(spacing.x * column as f64, spacing.y * row as f64 + offset_y_fraction * spacing.x) -} - -fn calculate_isometric_top_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { - let top_left = calculate_isometric_point(0, 0, angles, spacing); - let top_right = calculate_isometric_point(columns - 1, 0, angles, spacing); - - let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(spacing.x * 0.5, 0.) }; - let isometric_spacing = calculate_isometric_offset(spacing, angles); - let isometric_offset = DVec2::new(0., isometric_spacing.y); - let end_isometric_offset = if columns.is_multiple_of(2) { DVec2::ZERO } else { DVec2::new(0., isometric_spacing.y) }; - - (top_left + offset - isometric_offset, top_right - offset - end_isometric_offset) -} - -fn calculate_isometric_bottom_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { - let bottom_left = calculate_isometric_point(0, rows - 1, angles, spacing); - let bottom_right = calculate_isometric_point(columns - 1, rows - 1, angles, spacing); - - let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(spacing.x * 0.5, 0.) }; - let isometric_offset = if columns.is_multiple_of(2) { - let offset = calculate_isometric_offset(spacing, angles); - DVec2::new(0., offset.y) - } else { - DVec2::ZERO - }; - - (bottom_left + offset, bottom_right - offset + isometric_offset) -} - -fn calculate_isometric_offset(spacing: DVec2, angles: DVec2) -> DVec2 { - let first_point = calculate_isometric_point(0, 0, angles, spacing); - let second_point = calculate_isometric_point(1, 0, angles, spacing); - - DVec2::new(first_point.x - second_point.x, first_point.y - second_point.y) -} - -fn calculate_isometric_right_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { - let top_right = calculate_isometric_point(columns - 1, 0, angles, spacing); - let bottom_right = calculate_isometric_point(columns - 1, rows - 1, angles, spacing); - - let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(0., -spacing.y * 0.5) }; - - (top_right - offset, bottom_right + offset) -} - -fn calculate_isometric_left_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { - let top_left = calculate_isometric_point(0, 0, angles, spacing); - let bottom_left = calculate_isometric_point(0, rows - 1, angles, spacing); - - let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(0., -spacing.y * 0.5) }; - - (top_left - offset, bottom_left + offset) -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum RowColumnGizmoType { - #[default] - None, - Top, - Bottom, - Left, - Right, -} - -impl RowColumnGizmoType { - pub fn get_line_points(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { - match grid_type { - GridType::Rectangular => match self { - Self::Top => get_rectangle_top_line_points(columns, rows, spacing), - Self::Right => get_rectangle_right_line_points(columns, rows, spacing), - Self::Bottom => get_rectangle_bottom_line_points(columns, rows, spacing), - Self::Left => get_rectangle_left_line_points(columns, rows, spacing), - Self::None => panic!("RowColumnGizmoType::None does not have line points"), - }, - GridType::Isometric => match self { - Self::Top => calculate_isometric_top_line_points(columns, rows, spacing, angles), - Self::Right => calculate_isometric_right_line_points(columns, rows, spacing, angles), - Self::Bottom => calculate_isometric_bottom_line_points(columns, rows, spacing, angles), - Self::Left => calculate_isometric_left_line_points(columns, rows, spacing, angles), - Self::None => panic!("RowColumnGizmoType::None does not have line points"), - }, - } - } - - fn line(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, viewport: DAffine2) -> Line { - let (p0, p1) = self.get_line_points(grid_type, columns, rows, spacing, angles); - let direction = self.direction(viewport); - let gap = GRID_ROW_COLUMN_GIZMO_OFFSET * viewport.inverse().transform_vector2(direction).normalize(); - - convert_to_gizmo_line(viewport.transform_point2(p0 + gap), viewport.transform_point2(p1 + gap)) - } - - fn rect(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, viewport: DAffine2) -> Rect { - let (p0, p1) = self.get_line_points(grid_type, columns, rows, spacing, angles); - let direction = self.direction(viewport); - let gap = GRID_ROW_COLUMN_GIZMO_OFFSET * direction.normalize(); - - let (x0, x1) = match self { - Self::Top | Self::Left => (viewport.transform_point2(p0 + gap), viewport.transform_point2(p1)), - Self::Bottom | Self::Right => (viewport.transform_point2(p0), viewport.transform_point2(p1 + gap)), - Self::None => panic!("RowColumnGizmoType::None does not have opposite"), - }; - - Rect::new(x0.x, x0.y, x1.x, x1.y) - } - - fn opposite_gizmo_type(&self) -> Self { - match self { - Self::Top => Self::Bottom, - Self::Right => Self::Left, - Self::Bottom => Self::Top, - Self::Left => Self::Right, - Self::None => panic!("RowColumnGizmoType::None does not have opposite"), - } - } - - pub fn direction(&self, viewport: DAffine2) -> DVec2 { - match self { - RowColumnGizmoType::Top => viewport.transform_vector2(-DVec2::Y), - RowColumnGizmoType::Bottom => viewport.transform_vector2(DVec2::Y), - RowColumnGizmoType::Right => viewport.transform_vector2(DVec2::X), - RowColumnGizmoType::Left => viewport.transform_vector2(-DVec2::X), - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a line"), - } - } - - fn initial_dimension(&self, rows: u32, columns: u32) -> u32 { - match self { - RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => rows, - RowColumnGizmoType::Left | RowColumnGizmoType::Right => columns, - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), - } - } - - fn spacing(&self, spacing: DVec2, grid_type: GridType, angles: DVec2) -> f64 { - match self { - RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => spacing.y, - RowColumnGizmoType::Left | RowColumnGizmoType::Right => { - if grid_type == GridType::Rectangular { - spacing.x - } else { - spacing.y / (angles.x.to_radians().tan() + angles.y.to_radians().tan()) - } - } - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), - } - } - - fn parameter(&self) -> ParameterRef { - use graphene_std::vector::generator_nodes::grid::*; - - match self { - RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => RowsInput.into(), - RowColumnGizmoType::Left | RowColumnGizmoType::Right => ColumnsInput.into(), - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not reference a grid input"), - } - } - - pub fn mouse_icon(&self) -> MouseCursorIcon { - match self { - RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => MouseCursorIcon::NSResize, - RowColumnGizmoType::Left | RowColumnGizmoType::Right => MouseCursorIcon::EWResize, - RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), - } - } - - pub fn all() -> [Self; 4] { - [Self::Top, Self::Right, Self::Bottom, Self::Left] - } -} diff --git a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs deleted file mode 100644 index 0ef1b606b7..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod grid_rows_columns_gizmo; diff --git a/editor/src/messages/tool/common_functionality/shapes/grid_shape.rs b/editor/src/messages/tool/common_functionality/shapes/grid_shape.rs index 2548461107..d164af01d6 100644 --- a/editor/src/messages/tool/common_functionality/shapes/grid_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/grid_shape.rs @@ -1,83 +1,21 @@ use super::shape_utility::ShapeToolModifierKey; use super::*; +use crate::consts::GRID_ROW_COLUMN_GIZMO_OFFSET; +use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; -use crate::messages::tool::common_functionality::gizmos::shape_gizmos::grid_rows_columns_gizmo::{RowColumnGizmo, RowColumnGizmoState}; use crate::messages::tool::common_functionality::graph_modification_utils; -use crate::messages::tool::common_functionality::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::tool_messages::tool_prelude::*; +use glam::DAffine2; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; +use graphene_std::ParameterRef; use graphene_std::vector::misc::GridType; +use graphene_std::vector::misc::dvec2_to_point; +use kurbo::ParamCurveNearest; use std::collections::VecDeque; -#[derive(Clone, Debug, Default)] -pub struct GridGizmoHandler { - row_column_gizmo: RowColumnGizmo, -} - -impl ShapeGizmoHandler for GridGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - self.row_column_gizmo.is_hovered() - } - - fn handle_state(&mut self, selected_grid_layer: LayerNodeIdentifier, mouse_position: DVec2, document: &DocumentMessageHandler, _responses: &mut VecDeque) { - self.row_column_gizmo.handle_actions(selected_grid_layer, mouse_position, document); - } - - fn handle_click(&mut self) { - if self.row_column_gizmo.is_hovered() { - self.row_column_gizmo.update_state(RowColumnGizmoState::Dragging); - } - } - - fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - if self.row_column_gizmo.is_dragging() { - self.row_column_gizmo.update(document, input, responses, drag_start); - } - } - - fn overlays( - &self, - document: &DocumentMessageHandler, - selected_grid_layer: Option, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - self.row_column_gizmo.overlays(document, selected_grid_layer, shape_editor, mouse_position, overlay_context); - } - - fn dragging_overlays( - &self, - document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - shape_editor: &mut &mut ShapeState, - mouse_position: DVec2, - overlay_context: &mut OverlayContext, - ) { - if self.row_column_gizmo.is_dragging() { - self.row_column_gizmo.overlays(document, None, shape_editor, mouse_position, overlay_context); - } - } - - fn cleanup(&mut self) { - self.row_column_gizmo.cleanup(); - } - - fn mouse_cursor_icon(&self) -> Option { - if self.row_column_gizmo.is_hovered() || self.row_column_gizmo.is_dragging() { - return Some(self.row_column_gizmo.gizmo_type.mouse_icon()); - } - - None - } -} - #[derive(Default)] pub struct Grid; @@ -248,3 +186,284 @@ fn calculate_isometric_x_position(y_spacing: f64, rad_a: f64, rad_b: f64) -> f64 let spacing_x = y_spacing / (rad_a.tan() + rad_b.tan()); spacing_x * 9. } + +// --- Gizmo geometry ------------------------------------------------------------------------------- +// +// Where a grid's four draggable edges sit, for both the rectangular and the isometric layout. This is the +// grid's own geometry rather than gizmo machinery, so it lives with the shape; the interaction that uses it +// is declared in the gizmo registry. + +pub fn check_if_over_gizmo(grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, mouse_position: DVec2, viewport: DAffine2) -> Option { + let mouse_point = dvec2_to_point(mouse_position); + let accuracy = 1e-6; + let threshold = 32.; + + for gizmo_type in RowColumnGizmoType::all() { + let line = gizmo_type.line(grid_type, columns, rows, spacing, angles, viewport); + let rect = gizmo_type.rect(grid_type, columns, rows, spacing, angles, viewport); + + if rect.contains(mouse_point) || line.nearest(mouse_point, accuracy).distance_sq < threshold { + return Some(gizmo_type); + } + } + + None +} + +fn convert_to_gizmo_line(p0: DVec2, p1: DVec2) -> kurbo::Line { + kurbo::Line { + p0: dvec2_to_point(p0), + p1: dvec2_to_point(p1), + } +} + +/// Get corners of the rectangular-grid. +/// Returns a tuple of (topleft,topright,bottomright,bottomleft) +fn get_corners(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2, DVec2, DVec2) { + let (width, height) = (spacing.x, spacing.y); + + let x_distance = (columns - 1) as f64 * width; + let y_distance = (rows - 1) as f64 * height; + + let point0 = DVec2::ZERO; + let point1 = DVec2::new(x_distance, 0.); + let point2 = DVec2::new(x_distance, y_distance); + let point3 = DVec2::new(0., y_distance); + + (point0, point1, point2, point3) +} + +fn get_rectangle_top_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { + let (top_left, top_right, _, _) = get_corners(columns, rows, spacing); + let offset = if columns == 1 || rows == 1 { + DVec2::ZERO + } else if columns == 2 { + DVec2::new(spacing.x * 0.25, 0.) + } else { + DVec2::new(spacing.x * 0.5, 0.) + }; + + (top_left + offset, top_right - offset) +} + +fn get_rectangle_bottom_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { + let (_, _, bottom_right, bottom_left) = get_corners(columns, rows, spacing); + let offset = if columns == 1 || rows == 1 { + DVec2::ZERO + } else if columns == 2 { + DVec2::new(spacing.x * 0.25, 0.) + } else { + DVec2::new(spacing.x * 0.5, 0.) + }; + + (bottom_left + offset, bottom_right - offset) +} + +fn get_rectangle_right_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { + let (_, top_right, bottom_right, _) = get_corners(columns, rows, spacing); + let offset = if columns == 1 || rows == 1 { + DVec2::ZERO + } else if rows == 2 { + DVec2::new(0., -spacing.y * 0.25) + } else { + DVec2::new(0., -spacing.y * 0.5) + }; + + (top_right - offset, bottom_right + offset) +} + +fn get_rectangle_left_line_points(columns: u32, rows: u32, spacing: DVec2) -> (DVec2, DVec2) { + let (top_left, _, _, bottom_left) = get_corners(columns, rows, spacing); + let offset = if columns == 1 || rows == 1 { + DVec2::ZERO + } else if rows == 2 { + DVec2::new(0., -spacing.y * 0.25) + } else { + DVec2::new(0., -spacing.y * 0.5) + }; + + (top_left - offset, bottom_left + offset) +} + +fn calculate_isometric_point(column: u32, row: u32, angles: DVec2, spacing: DVec2) -> DVec2 { + let tan_a = angles.x.to_radians().tan(); + let tan_b = angles.y.to_radians().tan(); + + let spacing = DVec2::new(spacing.y / (tan_a + tan_b), spacing.y); + + let a_angles_eaten = column.div_ceil(2) as f64; + let b_angles_eaten = (column / 2) as f64; + + let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a; + + DVec2::new(spacing.x * column as f64, spacing.y * row as f64 + offset_y_fraction * spacing.x) +} + +fn calculate_isometric_top_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { + let top_left = calculate_isometric_point(0, 0, angles, spacing); + let top_right = calculate_isometric_point(columns - 1, 0, angles, spacing); + + let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(spacing.x * 0.5, 0.) }; + let isometric_spacing = calculate_isometric_offset(spacing, angles); + let isometric_offset = DVec2::new(0., isometric_spacing.y); + let end_isometric_offset = if columns.is_multiple_of(2) { DVec2::ZERO } else { DVec2::new(0., isometric_spacing.y) }; + + (top_left + offset - isometric_offset, top_right - offset - end_isometric_offset) +} + +fn calculate_isometric_bottom_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { + let bottom_left = calculate_isometric_point(0, rows - 1, angles, spacing); + let bottom_right = calculate_isometric_point(columns - 1, rows - 1, angles, spacing); + + let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(spacing.x * 0.5, 0.) }; + let isometric_offset = if columns.is_multiple_of(2) { + let offset = calculate_isometric_offset(spacing, angles); + DVec2::new(0., offset.y) + } else { + DVec2::ZERO + }; + + (bottom_left + offset, bottom_right - offset + isometric_offset) +} + +fn calculate_isometric_offset(spacing: DVec2, angles: DVec2) -> DVec2 { + let first_point = calculate_isometric_point(0, 0, angles, spacing); + let second_point = calculate_isometric_point(1, 0, angles, spacing); + + DVec2::new(first_point.x - second_point.x, first_point.y - second_point.y) +} + +fn calculate_isometric_right_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { + let top_right = calculate_isometric_point(columns - 1, 0, angles, spacing); + let bottom_right = calculate_isometric_point(columns - 1, rows - 1, angles, spacing); + + let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(0., -spacing.y * 0.5) }; + + (top_right - offset, bottom_right + offset) +} + +fn calculate_isometric_left_line_points(columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { + let top_left = calculate_isometric_point(0, 0, angles, spacing); + let bottom_left = calculate_isometric_point(0, rows - 1, angles, spacing); + + let offset = if columns == 1 || rows == 1 { DVec2::ZERO } else { DVec2::new(0., -spacing.y * 0.5) }; + + (top_left - offset, bottom_left + offset) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RowColumnGizmoType { + #[default] + None, + Top, + Bottom, + Left, + Right, +} + +impl RowColumnGizmoType { + pub fn get_line_points(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2) -> (DVec2, DVec2) { + match grid_type { + GridType::Rectangular => match self { + Self::Top => get_rectangle_top_line_points(columns, rows, spacing), + Self::Right => get_rectangle_right_line_points(columns, rows, spacing), + Self::Bottom => get_rectangle_bottom_line_points(columns, rows, spacing), + Self::Left => get_rectangle_left_line_points(columns, rows, spacing), + Self::None => panic!("RowColumnGizmoType::None does not have line points"), + }, + GridType::Isometric => match self { + Self::Top => calculate_isometric_top_line_points(columns, rows, spacing, angles), + Self::Right => calculate_isometric_right_line_points(columns, rows, spacing, angles), + Self::Bottom => calculate_isometric_bottom_line_points(columns, rows, spacing, angles), + Self::Left => calculate_isometric_left_line_points(columns, rows, spacing, angles), + Self::None => panic!("RowColumnGizmoType::None does not have line points"), + }, + } + } + + pub fn line(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, viewport: DAffine2) -> kurbo::Line { + let (p0, p1) = self.get_line_points(grid_type, columns, rows, spacing, angles); + let direction = self.direction(viewport); + let gap = GRID_ROW_COLUMN_GIZMO_OFFSET * viewport.inverse().transform_vector2(direction).normalize(); + + convert_to_gizmo_line(viewport.transform_point2(p0 + gap), viewport.transform_point2(p1 + gap)) + } + + pub fn rect(&self, grid_type: GridType, columns: u32, rows: u32, spacing: DVec2, angles: DVec2, viewport: DAffine2) -> kurbo::Rect { + let (p0, p1) = self.get_line_points(grid_type, columns, rows, spacing, angles); + let direction = self.direction(viewport); + let gap = GRID_ROW_COLUMN_GIZMO_OFFSET * direction.normalize(); + + let (x0, x1) = match self { + Self::Top | Self::Left => (viewport.transform_point2(p0 + gap), viewport.transform_point2(p1)), + Self::Bottom | Self::Right => (viewport.transform_point2(p0), viewport.transform_point2(p1 + gap)), + Self::None => panic!("RowColumnGizmoType::None does not have opposite"), + }; + + kurbo::Rect::new(x0.x, x0.y, x1.x, x1.y) + } + + pub fn opposite_gizmo_type(&self) -> Self { + match self { + Self::Top => Self::Bottom, + Self::Right => Self::Left, + Self::Bottom => Self::Top, + Self::Left => Self::Right, + Self::None => panic!("RowColumnGizmoType::None does not have opposite"), + } + } + + pub fn direction(&self, viewport: DAffine2) -> DVec2 { + match self { + RowColumnGizmoType::Top => viewport.transform_vector2(-DVec2::Y), + RowColumnGizmoType::Bottom => viewport.transform_vector2(DVec2::Y), + RowColumnGizmoType::Right => viewport.transform_vector2(DVec2::X), + RowColumnGizmoType::Left => viewport.transform_vector2(-DVec2::X), + RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a line"), + } + } + + pub fn initial_dimension(&self, rows: u32, columns: u32) -> u32 { + match self { + RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => rows, + RowColumnGizmoType::Left | RowColumnGizmoType::Right => columns, + RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), + } + } + + pub fn spacing(&self, spacing: DVec2, grid_type: GridType, angles: DVec2) -> f64 { + match self { + RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => spacing.y, + RowColumnGizmoType::Left | RowColumnGizmoType::Right => { + if grid_type == GridType::Rectangular { + spacing.x + } else { + spacing.y / (angles.x.to_radians().tan() + angles.y.to_radians().tan()) + } + } + RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), + } + } + + pub fn parameter(&self) -> ParameterRef { + use graphene_std::vector::generator_nodes::grid::*; + + match self { + RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => RowsInput.into(), + RowColumnGizmoType::Left | RowColumnGizmoType::Right => ColumnsInput.into(), + RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not reference a grid input"), + } + } + + pub fn mouse_icon(&self) -> MouseCursorIcon { + match self { + RowColumnGizmoType::Top | RowColumnGizmoType::Bottom => MouseCursorIcon::NSResize, + RowColumnGizmoType::Left | RowColumnGizmoType::Right => MouseCursorIcon::EWResize, + RowColumnGizmoType::None => panic!("RowColumnGizmoType::None does not have a mouse_icon"), + } + } + + pub fn all() -> [Self; 4] { + [Self::Top, Self::Right, Self::Bottom, Self::Left] + } +} From e5ba9aebc7e60eeff89f66b2bce84688f60ca8bf Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:25:00 +0530 Subject: [PATCH 16/24] Name the gizmo hook signatures Six function-pointer fields spelled out inline made the struct hard to read and tripped clippy's complex-type lint. The aliases also give the hooks somewhere to be documented individually. --- .../gizmos/gizmo_registry.rs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index 6cb6f482de..2214d0d01d 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -127,6 +127,19 @@ impl DragWrites { } } +/// Values a drag should settle onto, computed from the layer's other parameters. +pub type SnapTargetsFn = fn(&GizmoContext) -> Vec; +/// Extra overlay a shape draws around its gizmo. +pub type OverlayFn = fn(&GizmoContext, &mut OverlayContext); +/// Inputs to write alongside the dragged one. +pub type CoupledWritesFn = fn(&GizmoContext, f64) -> Vec<(ParameterRef, TaggedValue)>; +/// Where a parameter can be grabbed, in the layer's local space. +pub type HandlePositionsFn = fn(&GizmoContext, f64) -> Vec; +/// How far the cursor is from each grab point. +pub type HoverDistancesFn = fn(&GizmoContext) -> Vec>; +/// How cursor motion becomes node inputs. +pub type DragFn = fn(&GizmoContext, &mut DragInput) -> DragWrites; + /// The escape hatch for nodes whose gizmo needs more than the generic mechanics. /// /// The generic layer always owns hit-testing, the hover/drag state machine, the handle overlay, and the @@ -138,26 +151,26 @@ impl DragWrites { #[derive(Clone, Copy, Debug)] pub struct GizmoBehavior { /// Values the drag should snap to, recomputed from the layer's current parameters at drag start. - pub snap_targets: Option Vec>, + pub snap_targets: Option, /// Extra overlay drawn while this gizmo is hovered or dragged, for nodes that show more than the /// bare handle (a star previews its outline and spokes, for instance). - pub overlay: Option, + pub overlay: Option, /// Additional inputs to write alongside the dragged one, given the value the drag produced. Used by /// parameters that are only meaningful in combination. - pub coupled_writes: Option Vec<(ParameterRef, TaggedValue)>>, + pub coupled_writes: Option, /// Where this parameter can be grabbed, in the layer's local space, given its current value. /// /// A length parameter is usually grabbable in exactly one place, which is the default: a handle sitting /// `value` out along the local +X axis. Some shapes offer the same parameter in several places at once — /// a star's outer radius can be taken hold of at any of its outer points — so this returns all of them. /// The drag then runs along the ray through whichever one the user grabbed, not along a fixed axis. - pub handle_positions: Option Vec>, + pub handle_positions: Option, /// How far the cursor is from each of this parameter's grab points, when they are not points. /// /// The default measures to the handle positions above, which suits a control you take hold of at a /// spot. A grid's rows are grabbed anywhere along an edge, so it measures to a line instead. `None` in /// a slot means that grab point is not available right now. - pub hover_distances: Option Vec>>, + pub hover_distances: Option, /// How cursor motion becomes node inputs. /// /// The default reads the cursor's distance along the ray through the grabbed handle and writes the one @@ -171,7 +184,7 @@ pub struct GizmoBehavior { /// The [`DragInput`] is mutable because a drag may have to re-anchor itself: an arc dragged past a full /// sweep hands over to its other endpoint and continues from there, which means rewriting the baseline /// the rest of the gesture is measured against. - pub drag: Option DragWrites>, + pub drag: Option, /// Per-frame rotation, in degrees, below which swept angle is treated as cursor noise rather than /// intent. Near the layer's origin the angle between successive cursor positions is mostly noise, and /// feeding it in makes the value jitter while the cursor is still. Zero accumulates everything. From b295cbf5efa7d9815e2c4cf01b6f1961b5f65d71 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:43:47 +0530 Subject: [PATCH 17/24] Restore the circumference grab for circle and arc radii, and stop the grid drawing a stray handle Auditing the migrated shapes against the handlers they replaced turned up three regressions, two of them shipped and one of them mine. A circle's radius was never grabbed at a point. The hand-written handler took the whole circumference, through a band that widened with the stroke so a thick outline stayed grabbable at its edge, and narrowed for a circle small on screen so the band could not swallow the shape. It drew that band as a pair of dashed ellipses rather than a handle. Moving the circle to the plain slider replaced all of it with a single dot on the +X axis; the arc's radius lost the same when it followed. `CIRCULAR_RADIUS` puts it back for both. It reports the radial distance from the circumference rather than a flat yes, so an arc's endpoints still win the cursor where the two overlap -- which the hand-written version could not do, since its two gizmos hovered independently. The grid, meanwhile, was drawing a handle dot and a line to it at local `(row_count, 0)`: a point with no meaning, left over from the default handle position, sitting inside the grid. `draws_own_handle` marks the shapes whose overlay already draws the thing being grabbed. `hover_distances` now also owns its own range. A shape that answers the hover question knows how far is too far, and the point threshold that suits a handle is wrong for a band or an edge. --- .../generic_gizmos/generic_slider_gizmo.rs | 15 ++- .../gizmos/gizmo_behaviors.rs | 116 +++++++++++++++++- .../gizmos/gizmo_registry.rs | 9 +- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index c5a610ce93..8cd122f279 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -184,7 +184,6 @@ impl GenericSliderGizmo { self.hover_distances(document, value, mouse_position, viewport, center) .into_iter() .flatten() - .filter(|distance| *distance <= SLIDER_HANDLE_HOVER_THRESHOLD) .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) } @@ -198,8 +197,13 @@ impl GenericSliderGizmo { self.handle_positions(document, value) .into_iter() .map(|local| viewport.transform_point2(local)) - // Hide the gizmo when the shape is too small on screen to interact with reliably. - .map(|handle| (handle.distance(center) >= GIZMO_HIDE_THRESHOLD).then(|| mouse_position.distance(handle))) + .map(|handle| { + // Hide the gizmo when the shape is too small on screen to interact with reliably. + let reachable = handle.distance(center) >= GIZMO_HIDE_THRESHOLD; + let distance = mouse_position.distance(handle); + + (reachable && distance <= SLIDER_HANDLE_HOVER_THRESHOLD).then_some(distance) + }) .collect() } @@ -379,6 +383,11 @@ impl GenericSliderGizmo { return; } + // A shape that draws the thing being grabbed does not want a second handle on top of it. + if self.info.behavior.draws_own_handle { + return; + } + let Some(value) = self.current_value(document) else { return }; let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 1fa2bc5b04..18851743fe 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -14,11 +14,12 @@ use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, DragWrites, GizmoBehavior, GizmoContext, GizmoState}; -use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_stroke_width}; use crate::messages::tool::common_functionality::shapes::grid_shape::RowColumnGizmoType; use crate::messages::tool::common_functionality::shapes::shape_utility::{ - arc_end_points, arc_end_points_ignore_layer, arc_outline, calculate_arc_text_transform, draw_snapping_ticks, extract_arc_parameters, extract_grid_parameters, extract_polygon_parameters, - extract_spiral_parameters, extract_star_parameters, format_rounded, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, star_vertex_position, + arc_end_points, arc_end_points_ignore_layer, arc_outline, calculate_arc_text_transform, draw_snapping_ticks, extract_arc_parameters, extract_circle_radius, extract_grid_parameters, + extract_polygon_parameters, extract_spiral_parameters, extract_star_parameters, format_rounded, inside_polygon, inside_star, polygon_outline, polygon_vertex_position, star_outline, + star_vertex_position, }; use crate::messages::tool::common_functionality::shapes::spiral_shape::calculate_spiral_endpoints; use glam::{DAffine2, DVec2}; @@ -29,7 +30,7 @@ use graphene_std::vector::algorithms::shapes::{calculate_growth_factor, spiral_p use graphene_std::vector::generator_nodes::star; use graphene_std::vector::misc::{GridType, SpiralType, dvec2_to_point, get_line_endpoints}; use kurbo::ParamCurveNearest; -use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_4, PI, SQRT_2, TAU}; +use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, FRAC_PI_4, PI, SQRT_2, TAU}; /// The star's sides dial: previews the shape it is about to change. pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { @@ -40,6 +41,7 @@ pub const STAR_SIDES: GizmoBehavior = GizmoBehavior { hover_distances: None, drag: None, angle_deadzone: 0., + draws_own_handle: false, }; /// Either of the star's radius handles: snaps to the radii where the star's points line up, and previews @@ -52,6 +54,20 @@ pub const STAR_RADIUS: GizmoBehavior = GizmoBehavior { hover_distances: None, drag: None, angle_deadzone: 0., + draws_own_handle: false, +}; + +/// A circular radius, for the circle and the arc. Grabbed anywhere on the circumference rather than at one +/// point on it, which is how the hand-written handler worked and what the shape invites. +pub const CIRCULAR_RADIUS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(circular_radius_overlay), + coupled_writes: None, + handle_positions: None, + hover_distances: Some(circular_radius_distances), + drag: None, + angle_deadzone: 0., + draws_own_handle: true, }; /// The grid's row count, grabbed along its top or bottom edge. @@ -63,6 +79,7 @@ pub const GRID_ROWS: GizmoBehavior = GizmoBehavior { hover_distances: Some(grid_row_distances), drag: Some(grid_edge_drag), angle_deadzone: 0., + draws_own_handle: true, }; /// The grid's column count, grabbed along its left or right edge. @@ -74,6 +91,7 @@ pub const GRID_COLUMNS: GizmoBehavior = GizmoBehavior { hover_distances: Some(grid_column_distances), drag: Some(grid_edge_drag), angle_deadzone: 0., + draws_own_handle: true, }; /// The arc's sweep, grabbable at either end of the curve. Dragging either endpoint reshapes the arc; the @@ -86,6 +104,7 @@ pub const ARC_SWEEP: GizmoBehavior = GizmoBehavior { hover_distances: None, drag: Some(arc_sweep_drag), angle_deadzone: 0., + draws_own_handle: false, }; /// The spiral's winding control. Grabbable at either end of the curve; dragging winds or unwinds it. @@ -97,6 +116,7 @@ pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { hover_distances: None, drag: Some(spiral_turns_drag), angle_deadzone: 0.5, + draws_own_handle: false, }; /// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. @@ -108,6 +128,7 @@ pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { hover_distances: None, drag: None, angle_deadzone: 0., + draws_own_handle: false, }; /// The radii at which dragging one of a star's radius handles makes its points line up: the value where @@ -722,3 +743,90 @@ fn set_initial_u32(drag: &mut DragInput, index: usize, value: u32) { *slot = Some(TaggedValue::U32(value)); } } + +/// A point on a circle of the given radius, at `theta` measured counterclockwise from +X. +fn circle_point(theta: f64, radius: f64) -> DVec2 { + DVec2::new(radius * theta.cos(), -radius * theta.sin()) +} + +/// Half the width of the band around the circumference that counts as grabbing it. It widens with the +/// stroke, so a thick outline is still grabbable at its edge, and narrows for a circle that is small on +/// screen so the band cannot swallow the whole shape. +fn circular_grab_spacing(viewport: DAffine2, radius: f64, center: DVec2, stroke_width: f64) -> f64 { + const SMALL_ON_SCREEN: f64 = 15.; + + let x_extent = viewport.transform_point2(circle_point(0., radius)).distance(center); + let y_extent = viewport.transform_point2(circle_point(FRAC_PI_2, radius)).distance(center); + let smallest = x_extent.min(y_extent); + + stroke_width + if smallest < SMALL_ON_SCREEN { 10. * (smallest / SMALL_ON_SCREEN) } else { 10. } +} + +/// The radius this gizmo edits, whichever of the two shapes owns it. +fn circular_radius(context: &GizmoContext) -> Option { + extract_circle_radius(context.layer, context.document).or_else(|| extract_arc_parameters(Some(context.layer), context.document).map(|(radius, ..)| radius)) +} + +/// How far the cursor is from the circumference, or `None` when it is not on it. Reporting the radial +/// distance rather than a flat "yes" lets an arc's endpoints still win the cursor where they overlap. +fn circular_radius_distances(context: &GizmoContext) -> Vec> { + let Some(radius) = circular_radius(context) else { return vec![None] }; + let radius = radius.abs(); + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + let angle = viewport.inverse().transform_point2(context.mouse_position).angle_to(DVec2::X); + let on_circumference = viewport.transform_point2(circle_point(angle, radius)); + + // Too small on screen to aim at. + if on_circumference.distance(center) < GIZMO_HIDE_THRESHOLD { + return vec![None]; + } + + let stroke_width = get_stroke_width(context.layer, &context.document.network_interface).unwrap_or(0.); + let spacing = circular_grab_spacing(viewport, radius, center, stroke_width); + let deviation = (context.mouse_position.distance(center) - on_circumference.distance(center)).abs(); + + vec![(deviation <= spacing).then_some(deviation)] +} + +/// The band itself, drawn as a pair of dashed ellipses once the radius is in play. +fn circular_radius_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + if context.state == GizmoState::Inactive { + return; + } + let Some(radius) = circular_radius(context) else { return }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + let x_point = viewport.transform_point2(circle_point(0., radius)); + let y_point = viewport.transform_point2(circle_point(FRAC_PI_2, radius)); + + let Some(stroke_width) = get_stroke_width(context.layer, &context.document.network_interface) else { + overlay_context.dashed_ellipse( + center, + x_point.distance(center), + y_point.distance(center), + None, + None, + None, + None, + None, + None, + Some(4.), + Some(4.), + Some(0.5), + ); + return; + }; + + let spacing = circular_grab_spacing(viewport, radius, center, stroke_width); + let direction_x = viewport.transform_vector2(DVec2::X); + let direction_y = viewport.transform_vector2(-DVec2::Y); + + for sign in [-1., 1.] { + let x_radius = (x_point + direction_x * spacing * sign).distance(center); + let y_radius = (y_point + direction_y * spacing * sign).distance(center); + overlay_context.dashed_ellipse(center, x_radius, y_radius, None, None, None, None, None, None, Some(4.), Some(4.), Some(0.5)); + } +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index 2214d0d01d..d03be32ead 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -189,6 +189,10 @@ pub struct GizmoBehavior { /// intent. Near the layer's origin the angle between successive cursor positions is mostly noise, and /// feeding it in makes the value jitter while the cursor is still. Zero accumulates everything. pub angle_deadzone: f64, + /// Set when the shape's `overlay` already draws whatever the user grabs, so the generic handle dot and + /// its line from the origin are suppressed. A grid marks its edge with a dashed line and a circle draws + /// the band around its circumference; neither has a handle sitting at a point. + pub draws_own_handle: bool, } impl GizmoBehavior { @@ -201,6 +205,7 @@ impl GizmoBehavior { hover_distances: None, drag: None, angle_deadzone: 0., + draws_own_handle: false, }; } @@ -232,7 +237,7 @@ const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { name: "Radius", min: Some(0.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::CIRCULAR_RADIUS, position_hint: PositionHint::ParameterDerived, }]; @@ -285,7 +290,7 @@ const ARC_GIZMOS: &[GizmoInfo] = &[ name: "Radius", min: Some(0.), max: None, - behavior: GizmoBehavior::NONE, + behavior: gizmo_behaviors::CIRCULAR_RADIUS, position_hint: PositionHint::ParameterDerived, }, // One entry, not two: dragging either endpoint can move the start angle and the sweep together, so a From 74dc71c68ec79ec6456c110933b8aad59715b410 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 23 Aug 2026 02:45:29 +0530 Subject: [PATCH 18/24] Match the sides dial to the one it replaced Two differences from the hand-written dial, both dating from the original migration. It stepped every 25 pixels of drag, not 20, so the generic one turned a quarter faster than the shape it replaced. And it disappeared once the shape was small enough on screen that the dial would cover it -- the generic one's check compared a single local unit against nothing meaningful and never fired, so the dial stayed live on a shape a few pixels across and swallowed presses meant for the layer. The replacement measures the layer's bounding box, which is what "too small to sit around" actually means and does not depend on the shape's own parameters. --- .../gizmos/generic_gizmos/generic_dial_gizmo.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs index 937196a9dd..f5b75db7fe 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -6,7 +6,7 @@ //! //! [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry -use crate::consts::NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH; +use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH}; use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::message::Message; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -25,7 +25,7 @@ use graphene_std::ParameterRef; use std::collections::VecDeque; /// Horizontal drag distance (viewport px) that corresponds to one integer step. -const DIAL_PIXELS_PER_STEP: f64 = 20.; +const DIAL_PIXELS_PER_STEP: f64 = 25.; /// Viewport radius of the drawn dial indicator. const DIAL_INDICATOR_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH; /// Viewport radius of the clickable hit area. Deliberately larger than the drawn indicator so the @@ -122,9 +122,10 @@ impl GenericDialGizmo { let viewport = document.metadata().transform_to_viewport(self.layer); let center = viewport.transform_point2(DVec2::ZERO); - // Hide the dial when the shape is degenerate on screen. - let extent = viewport.transform_point2(DVec2::new(1., 0.)).distance(center); - if extent < f64::EPSILON { + // Hide the dial once the shape is too small on screen to sit around: the hit disc would cover the + // whole thing, and a press meant for the layer would be swallowed by the gizmo. + let bounds = document.metadata().bounding_box_viewport(self.layer)?; + if (bounds[1] - bounds[0]).max_element() / 2. < GIZMO_HIDE_THRESHOLD { return None; } From 6099aa24713f829604a39a1bbdade0cd0dc6a258 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 16:53:24 +0530 Subject: [PATCH 19/24] Show a resting handle for gizmos that draw nothing of their own A shape that supplies an overlay has already put something on screen to aim at: the star marks every vertex, the grid dashes its edges, the circle draws the band around its circumference. A parameter with no behavior of its own drew nothing at all until the cursor happened to land within eight pixels of it -- a control you cannot find unless you already know it is there. The generic slider now marks its grab points at rest, but only when the shape draws nothing itself, so it never stacks a second handle on one already drawn. This matters most for what comes next: a node that adopts the registry without needing a behavior gets a discoverable control for free. --- .../generic_gizmos/generic_slider_gizmo.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 8cd122f279..74e20dfeb2 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -380,6 +380,12 @@ impl GenericSliderGizmo { } if self.state == GenericSliderState::Inactive { + // A shape that draws its own overlay has already put something on screen to aim at. One that + // does not would otherwise be invisible until the cursor happened to land on it, so the generic + // layer marks where it can be grabbed. + if self.info.behavior.overlay.is_none() && !self.info.behavior.draws_own_handle { + self.draw_resting_handles(document, overlay_context); + } return; } @@ -402,6 +408,23 @@ impl GenericSliderGizmo { overlay_context.manipulator_handle(handle, self.state == GenericSliderState::Dragging, None); } + /// Mark every grab point with an unengaged handle, so a control with no overlay of its own is still + /// discoverable before the cursor finds it. + fn draw_resting_handles(&self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { + let Some(value) = self.current_value(document) else { return }; + let viewport = document.metadata().transform_to_viewport(self.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + for local in self.handle_positions(document, value) { + let handle = viewport.transform_point2(local); + // Too small on screen to aim at, and the handle would sit on top of the shape's own centre. + if handle.distance(center) < GIZMO_HIDE_THRESHOLD { + continue; + } + overlay_context.manipulator_handle(handle, false, None); + } + } + pub fn mouse_cursor_icon(&self) -> Option { match self.state { GenericSliderState::Hover | GenericSliderState::Dragging => Some(MouseCursorIcon::EWResize), From 7b41ed1c65d40559938e1ac96ccad1e59ffd272a Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 16:55:49 +0530 Subject: [PATCH 20/24] Give the polygon back its radius handles Master let you grab a polygon's radius at any of its corners -- the same `PointRadiusHandle` that served the star. The registry dropped it, on the reasoning that a radius handle would land at `(radius, 0)`, off the polygon's own geometry, and that the transform cage covers the same ground. The first half of that stopped being true when `handle_positions` arrived: a shape can now put its handles wherever its geometry says they belong, which for a regular polygon is every corner, exactly as for the star. The second half was never quite right either -- the cage scales the layer, while the radius is a node input the graph can reason about. This leaves the polygon at parity with master rather than a control short of it. --- .../gizmos/gizmo_behaviors.rs | 52 +++++++++++++++++++ .../gizmos/gizmo_registry.rs | 42 +++++++++------ 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 18851743fe..87af0f4417 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -11,6 +11,7 @@ use crate::consts::{ARC_SNAP_THRESHOLD, COLOR_OVERLAY_RED}; use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_EXTENSION, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH, POINT_RADIUS_HANDLE_SEGMENT_THRESHOLD}; +use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{DragInput, DragWrites, GizmoBehavior, GizmoContext, GizmoState}; @@ -119,6 +120,18 @@ pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { draws_own_handle: false, }; +/// The polygon's radius, grabbable at any of its corners. +pub const POLYGON_RADIUS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: Some(polygon_radius_overlay), + coupled_writes: None, + handle_positions: Some(polygon_radius_handles), + hover_distances: None, + drag: None, + angle_deadzone: 0., + draws_own_handle: false, +}; + /// The polygon's sides dial, the counterpart to [`STAR_SIDES`]. pub const POLYGON_SIDES: GizmoBehavior = GizmoBehavior { snap_targets: None, @@ -830,3 +843,42 @@ fn circular_radius_overlay(context: &GizmoContext, overlay_context: &mut Overlay overlay_context.dashed_ellipse(center, x_radius, y_radius, None, None, None, None, None, None, Some(4.), Some(4.), Some(0.5)); } } + +/// A regular polygon's radius reaches every corner equally, so every corner is a grab point -- the same +/// arrangement as the star's, with one vertex per side rather than alternating between two radii. +fn polygon_radius_handles(context: &GizmoContext, value: f64) -> Vec { + let Some((sides, _)) = extract_polygon_parameters(Some(context.layer), context.document) else { + return Vec::new(); + }; + + (0..sides) + .map(|vertex| { + let angle = ((vertex as f64) * TAU) / (sides as f64); + DVec2::new(value * angle.sin(), -value * angle.cos()) + }) + .collect() +} + +/// Mark every corner at rest, and show the outline being resized once one is held. +fn polygon_radius_overlay(context: &GizmoContext, overlay_context: &mut OverlayContext) { + let Some((sides, radius)) = extract_polygon_parameters(Some(context.layer), context.document) else { + return; + }; + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let center = viewport.transform_point2(DVec2::ZERO); + + if context.state == GizmoState::Inactive { + for vertex in 0..sides { + let point = polygon_vertex_position(viewport, vertex as i32, sides, radius); + + // Once the polygon is this small the corners crowd its centre and cannot be told apart. + if point.distance(center) < GIZMO_HIDE_THRESHOLD { + return; + } + overlay_context.manipulator_handle(point, false, None); + } + return; + } + + polygon_outline(Some(context.layer), context.document, overlay_context); +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index d03be32ead..14fabdb971 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -243,15 +243,26 @@ const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { // Only the sides dial: a polygon's radius is already adjustable via the transform cage, and a // `(radius, 0)` slider handle lands off the polygon's geometry, so it adds confusion without value. -const POLYGON_GIZMOS: &[GizmoInfo] = &[GizmoInfo { - parameter_index: regular_polygon::SidesInput::INDEX, - gizmo_type: GizmoType::Dial, - name: "Sides", - min: Some(3.), - max: None, - behavior: gizmo_behaviors::POLYGON_SIDES, - position_hint: PositionHint::BoundingBoxCenter, -}]; +const POLYGON_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: regular_polygon::SidesInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Sides", + min: Some(3.), + max: None, + behavior: gizmo_behaviors::POLYGON_SIDES, + position_hint: PositionHint::BoundingBoxCenter, + }, + GizmoInfo { + parameter_index: regular_polygon::RadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + behavior: gizmo_behaviors::POLYGON_RADIUS, + position_hint: PositionHint::ParameterDerived, + }, +]; const STAR_GIZMOS: &[GizmoInfo] = &[ GizmoInfo { @@ -396,17 +407,20 @@ mod tests { } #[test] - fn polygon_exposes_only_a_sides_dial() { + fn polygon_exposes_a_sides_dial_and_a_radius() { let infos = get_gizmo_info(&generator_nodes::regular_polygon::IDENTIFIER); - assert_eq!(infos.len(), 1); + assert_eq!(infos.len(), 2); let sides = &infos[0]; assert_eq!(sides.gizmo_type, GizmoType::Dial); assert_eq!(sides.parameter_index, 1); assert_eq!(sides.min, Some(3.)); - // The radius is intentionally not exposed as a gizmo (handled by the transform cage instead). - assert!(infos.iter().all(|info| info.gizmo_type != GizmoType::Slider)); + // The radius is grabbable at the polygon's corners, so it needs somewhere to put those handles. + let radius = &infos[1]; + assert_eq!(radius.gizmo_type, GizmoType::Slider); + assert_eq!(radius.min, Some(0.)); + assert!(radius.behavior.handle_positions.is_some()); } #[test] @@ -420,10 +434,8 @@ mod tests { fn heart_exposes_only_a_radius_slider() { let infos = get_gizmo_info(&generator_nodes::heart::IDENTIFIER); assert_eq!(infos.len(), 1); - assert_eq!(infos[0].parameter_index, 1); assert_eq!(infos[0].gizmo_type, GizmoType::Slider); assert_eq!(infos[0].min, Some(0.)); - assert_eq!(infos[0].position_hint, PositionHint::ParameterDerived); } #[test] From c0e33025ea853d3b67767de3fa6bd85b5e154aa3 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 16:57:41 +0530 Subject: [PATCH 21/24] Put handles on the heart's cleavage and shoulders The heart node has eleven parameters and exposed one. Two of the other ten have an obvious place to grab on the shape: the notch between the lobes, and the width of the lobes themselves. The rest -- curvature, tilt, sharpness -- are shaping controls better set by number than by eye, and stay in the panel. Both are fractions of the radius rather than distances, which is the first thing the generic slider cannot do by default: its drag reads a distance along a ray and writes it straight through. So each supplies `handle_positions` to put its handle where `heart_bezpath` puts the corresponding anchor, and a `drag` that divides back out by the radius. Both hold to the ranges the node declares. That is not caution for its own sake: a notch deeper than the shoulders are high crosses its own lobes, and the heart disappears entirely rather than merely looking wrong. Not yet confirmed by hand -- the handles draw in the right places and the drags no longer break the geometry, but I have not watched either parameter move through its full range. --- .../gizmos/gizmo_behaviors.rs | 103 ++++++++++++++++++ .../gizmos/gizmo_registry.rs | 60 +++++++--- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 87af0f4417..6fd3659295 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -120,6 +120,30 @@ pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { draws_own_handle: false, }; +/// The heart's cleavage: the notch between its lobes, dragged straight down from the top. +pub const HEART_CLEAVAGE: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: None, + coupled_writes: None, + handle_positions: Some(heart_cleavage_handles), + hover_distances: None, + drag: Some(heart_cleavage_drag), + angle_deadzone: 0., + draws_own_handle: false, +}; + +/// The heart's shoulder width, grabbed at either lobe. +pub const HEART_SHOULDER: GizmoBehavior = GizmoBehavior { + snap_targets: None, + overlay: None, + coupled_writes: None, + handle_positions: Some(heart_shoulder_handles), + hover_distances: None, + drag: Some(heart_shoulder_drag), + angle_deadzone: 0., + draws_own_handle: false, +}; + /// The polygon's radius, grabbable at any of its corners. pub const POLYGON_RADIUS: GizmoBehavior = GizmoBehavior { snap_targets: None, @@ -882,3 +906,82 @@ fn polygon_radius_overlay(context: &GizmoContext, overlay_context: &mut OverlayC polygon_outline(Some(context.layer), context.document, overlay_context); } + +/// The bounds the heart node itself declares for these proportions. Past them the outline turns inside out +/// -- a notch deeper than the shoulders are high crosses its own lobes and renders as nothing -- so the +/// drags hold to them rather than to whatever the cursor asks for. +const HEART_CLEAVAGE_RANGE: (f64, f64) = (0., 0.6); +const HEART_SHOULDER_WIDTH_RANGE: (f64, f64) = (0., 1.4); + +/// The heart's proportions are fractions of its radius rather than distances, so its handles sit where the +/// geometry puts them and the drag converts back. See `heart_bezpath`, which these mirror. +fn heart_radius_and(context: &GizmoContext, index: usize) -> Option<(f64, f64)> { + use graphene_std::vector::generator_nodes::heart; + + let parameters = NodeGraphLayer::new(context.layer, &context.document.network_interface).find_node_parameters(heart::IDENTIFIER)?; + let Some(&TaggedValue::F64(radius)) = parameters.value(heart::RadiusInput) else { return None }; + let inputs = NodeGraphLayer::new(context.layer, &context.document.network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(heart::IDENTIFIER))?; + let Some(TaggedValue::F64(other)) = inputs.get(index)?.as_value() else { return None }; + + Some((radius, *other)) +} + +/// The notch sits on the vertical axis, `1 - cleavage_depth` of the radius above centre. +fn heart_cleavage_handles(context: &GizmoContext, value: f64) -> Vec { + use graphene_std::vector::generator_nodes::heart; + + let Some((radius, _)) = heart_radius_and(context, heart::CleavageDepthInput::INDEX) else { + return Vec::new(); + }; + + vec![DVec2::new(0., (-1. + value) * radius)] +} + +fn heart_cleavage_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { + use graphene_std::vector::generator_nodes::heart; + + let Some((radius, _)) = heart_radius_and(context, heart::CleavageDepthInput::INDEX) else { + return DragWrites::default(); + }; + if radius.abs() < f64::EPSILON { + return DragWrites::default(); + } + + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let local = viewport.inverse().transform_point2(drag.mouse_position); + + // Invert `(-1 + depth) * radius`, held to the node's own range. + let depth = (1. + local.y / radius).clamp(HEART_CLEAVAGE_RANGE.0, HEART_CLEAVAGE_RANGE.1); + + DragWrites::inputs(vec![(heart::CleavageDepthInput.into(), TaggedValue::F64(depth))]) +} + +/// Each shoulder sits `shoulder_width` of the radius out and `shoulder_height` up, mirrored across the axis. +fn heart_shoulder_handles(context: &GizmoContext, value: f64) -> Vec { + use graphene_std::vector::generator_nodes::heart; + + let Some((radius, height)) = heart_radius_and(context, heart::ShoulderHeightInput::INDEX) else { + return Vec::new(); + }; + + vec![DVec2::new(value * radius, -height * radius), DVec2::new(-value * radius, -height * radius)] +} + +fn heart_shoulder_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { + use graphene_std::vector::generator_nodes::heart; + + let Some((radius, _)) = heart_radius_and(context, heart::ShoulderHeightInput::INDEX) else { + return DragWrites::default(); + }; + if radius.abs() < f64::EPSILON { + return DragWrites::default(); + } + + let viewport = context.document.metadata().transform_to_viewport(context.layer); + let local = viewport.inverse().transform_point2(drag.mouse_position); + + // The lobes are mirrored, so whichever one is held reports the same positive width. + let width = (local.x.abs() / radius).clamp(HEART_SHOULDER_WIDTH_RANGE.0, HEART_SHOULDER_WIDTH_RANGE.1); + + DragWrites::inputs(vec![(heart::ShoulderWidthInput.into(), TaggedValue::F64(width))]) +} diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index 14fabdb971..c6c532bc41 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -332,15 +332,37 @@ const SPIRAL_GIZMOS: &[GizmoInfo] = &[GizmoInfo { // Only the radius slider: the heart's many shaping parameters (cleavage, lobes, shoulders, point) // are fine-tuned via the Properties panel, while the overall size reads naturally as a canvas handle. -const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { - parameter_index: heart::RadiusInput::INDEX, - gizmo_type: GizmoType::Slider, - name: "Radius", - min: Some(0.), - max: None, - behavior: GizmoBehavior::NONE, - position_hint: PositionHint::ParameterDerived, -}]; +// The heart has eleven parameters; these three are the ones with an obvious place to grab on the shape. +// The rest -- curvature, tilt, sharpness -- are shaping controls better set by number than by eye. +const HEART_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: heart::RadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + behavior: GizmoBehavior::NONE, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: heart::CleavageDepthInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Cleavage", + min: Some(0.), + max: Some(0.6), + behavior: gizmo_behaviors::HEART_CLEAVAGE, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: heart::ShoulderWidthInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Shoulder Width", + min: Some(0.), + max: Some(1.4), + behavior: gizmo_behaviors::HEART_SHOULDER, + position_hint: PositionHint::ParameterDerived, + }, +]; // Rows and columns only. The spacing was declared as a position gizmo that was never built, and a grid's // spacing is a two-axis value with no obvious handle on the shape -- it stays in the Properties panel. @@ -431,11 +453,23 @@ mod tests { } #[test] - fn heart_exposes_only_a_radius_slider() { + fn heart_exposes_radius_cleavage_and_shoulder() { let infos = get_gizmo_info(&generator_nodes::heart::IDENTIFIER); - assert_eq!(infos.len(), 1); - assert_eq!(infos[0].gizmo_type, GizmoType::Slider); - assert_eq!(infos[0].min, Some(0.)); + assert_eq!(infos.len(), 3); + + // The radius is a plain distance, so it needs no behavior of its own. + let radius = &infos[0]; + assert_eq!(radius.gizmo_type, GizmoType::Slider); + assert_eq!(radius.min, Some(0.)); + assert!(radius.behavior.handle_positions.is_none()); + + // The other two are fractions of the radius rather than distances, so each places its own handle + // on the geometry and converts back on drag. + for proportion in &infos[1..] { + assert!(proportion.behavior.handle_positions.is_some()); + assert!(proportion.behavior.drag.is_some()); + assert!(proportion.max.is_some()); + } } #[test] From b8dd11035a44ba0e5c9c0d4aafea2a24240dabec Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 19:00:32 +0530 Subject: [PATCH 22/24] Measure a gizmo drag from where it started, not from where the cursor is Two faults in how the generic slider turned cursor motion into a value, both visible the moment a handle was grabbed rather than dragged. It wrote the cursor's absolute position along the ray, so taking hold of a handle a few pixels off centre snapped the value before the drag had gone anywhere. The hand-written handlers all added a delta to the value they started from; this does too. And the point it measured from was the shape tool's drag start, which is the cursor's position after snapping to nearby geometry. That gap was spent as movement on the first frame, so a click that never moved still nudged the shape smaller. The first frame of a drag now fixes its own reference from the cursor itself, and re-reads the value it starts from at the same moment so the two cannot fall out of step. --- .../generic_gizmos/generic_slider_gizmo.rs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 74e20dfeb2..72011be231 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -256,11 +256,24 @@ impl GenericSliderGizmo { /// Update the parameter live while dragging. The new value is the mouse's position projected /// onto the local +X axis, clamped to the registry's min/max bounds. pub fn handle_update(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + // The first frame of a drag fixes the reference every later frame is measured against. Take it from + // the cursor itself rather than the tool's drag start: the two are in the same space, but the tool's + // may have been snapped to nearby geometry, and that offset would otherwise be spent as movement the + // instant the handle is grabbed -- a visible nudge on a click that never moved. + if self.drag_origin.is_none() { + self.drag_origin = Some(input.mouse.position); + self.previous_mouse_position = input.mouse.position; + if let Some(value) = self.current_value(document) { + self.initial_value = value; + } + } + let drag_start = self.drag_origin.unwrap_or(drag_start); + self.accumulate_angle(document, input.mouse.position); if let Some(drag) = self.info.behavior.drag { let mut drag_input = DragInput { - drag_start: self.drag_origin.unwrap_or(drag_start), + drag_start, mouse_position: input.mouse.position, initial_value: self.initial_value, initial_parameters: self.initial_parameters.clone(), @@ -303,16 +316,19 @@ impl GenericSliderGizmo { let local_mouse = viewport.inverse().transform_point2(input.mouse.position); // Project the cursor onto the ray through the grabbed handle. For the default single handle that ray - // is the +X axis and this is just `local_mouse.x`; for a handle sitting on the shape's own geometry - // it is the ray the user is visibly pulling along. + // is the +X axis; for a handle sitting on the shape's own geometry it is the ray the user is visibly + // pulling along. let Some(anchor) = self.active_handle_local(document, self.initial_value) else { return }; let ray = anchor.try_normalize().unwrap_or(DVec2::X); - let mut value = local_mouse.dot(ray); + + // Measure how far the cursor has travelled along that ray rather than where it now sits, so the value + // does not jump the instant the handle is grabbed a pixel off centre. This is what the hand-written + // handlers did, all of which added a delta to the value they started from. + let travelled = local_mouse.dot(ray) - viewport.inverse().transform_point2(drag_start).dot(ray); // Preserve the sign of the original value for parameters (like radius) that can be negative. - if self.initial_value.is_sign_negative() { - value = -value; - } + let direction = if self.initial_value.is_sign_negative() { -1. } else { 1. }; + let mut value = self.initial_value + travelled * direction; value = self.snap(self.clamp(value)); From b427443be7979919903e26e08d97d1f8668e8629 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 19:00:32 +0530 Subject: [PATCH 23/24] Read a circular radius drag horizontally, from wherever it was grabbed A circle and an arc can be grabbed anywhere on their circumference, which rules out every rotational way of reading the drag. Projecting onto the ray from the centre through the grabbed point is smooth, but dead to sideways movement at the top and bottom, where that movement is almost entirely along the curve. Counting the whole distance and taking only the direction from the ray -- what master does -- trades the deadness for a lurch: near the tangent the sign is decided by microscopic jitter and flips frame to frame. Both are worst exactly at the top and bottom. The two things wanted of it, that a vertical drag at three o'clock leave the radius alone and that a sideways drag at twelve o'clock move it, are the same motion at different angles. No rotationally symmetric rule gives both. So the drag reads horizontally from wherever it was grabbed: right grows, left shrinks, at every angle. Vertical movement no longer means anything, which is the price; in exchange there is no dead zone, no sign that flips, and no part of the curve that behaves unlike any other. This also fixes the original fault, that grabbing the left of a circle and pulling outward drove the radius to its lower bound and collapsed the shape. --- .../gizmos/gizmo_behaviors.rs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs index 6fd3659295..81ccd1d53c 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -66,7 +66,7 @@ pub const CIRCULAR_RADIUS: GizmoBehavior = GizmoBehavior { coupled_writes: None, handle_positions: None, hover_distances: Some(circular_radius_distances), - drag: None, + drag: Some(circular_radius_drag), angle_deadzone: 0., draws_own_handle: true, }; @@ -985,3 +985,34 @@ fn heart_shoulder_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrit DragWrites::inputs(vec![(heart::ShoulderWidthInput.into(), TaggedValue::F64(width))]) } + +/// A circumference can be grabbed at any angle, which rules out every rotational way of reading the drag. +/// +/// Running along the ray from the centre through the grabbed point is the obvious choice and behaves badly +/// at the top and bottom: a sideways slide there is almost entirely along the curve, so the radius either +/// barely moves, or -- if the whole distance is counted and only the direction taken from the ray -- lurches +/// in and out as microscopic vertical jitter flips the sign. The same motion has to mean two different +/// things at three o'clock and at twelve, and no rotationally symmetric rule can give both. +/// +/// So the drag reads horizontally, from wherever it was grabbed: right grows, left shrinks, whichever part +/// of the curve is being held. It gives up on vertical movement meaning anything, and in exchange there is +/// no dead zone, no sign that flips, and no angle where the control behaves differently from any other. +fn circular_radius_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { + let Some(radius) = circular_radius(context) else { return DragWrites::default() }; + + let inverse = context.document.metadata().transform_to_viewport(context.layer).inverse(); + let travelled = inverse.transform_point2(drag.mouse_position).x - inverse.transform_point2(drag.drag_start).x; + + // Keep the sign: a negative radius means an inside-out shape, and growing it should deepen that rather + // than flip it. + let magnitude = (drag.initial_value.abs() + travelled).max(0.); + let signed = if radius.is_sign_negative() { -magnitude } else { magnitude }; + + let parameter = if extract_circle_radius(context.layer, context.document).is_some() { + ParameterRef::from(graphene_std::vector::generator_nodes::circle::RadiusInput) + } else { + ParameterRef::from(graphene_std::vector::generator_nodes::arc::RadiusInput) + }; + + DragWrites::inputs(vec![(parameter, TaggedValue::F64(signed))]) +} From d30d5b501966352683830f33ecb71903978d61cf Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 24 Aug 2026 19:48:34 +0530 Subject: [PATCH 24/24] Write the guide for adding a gizmo to a node The registry's own module docs told you to add a `GizmoInfo` and register the node, which is true and covers the case where a parameter is a length measured from the origin. It said nothing about the seven hooks a node reaches for when it is not, and it still pointed at `shape_gizmos`, deleted when the last hand-written handler went. The guide leads with the smallest complete example, since most parameters need nothing else, and only then works through the hooks and when each earns its place. It ends with the things that actually cost time here: that a normalized parameter needs its own drag, that writing outside a node's hard range produces geometry the renderer cannot draw rather than a clamp, and that the transform cage sits on top of the grab points a reviewer will reach for first. --- .../common_functionality/gizmos/README.md | 153 ++++++++++++++++++ .../generic_gizmos/generic_slider_gizmo.rs | 2 +- .../gizmos/generic_gizmos/mod.rs | 2 +- .../gizmos/gizmo_registry.rs | 8 +- 4 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 editor/src/messages/tool/common_functionality/gizmos/README.md diff --git a/editor/src/messages/tool/common_functionality/gizmos/README.md b/editor/src/messages/tool/common_functionality/gizmos/README.md new file mode 100644 index 0000000000..9428e334ca --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/README.md @@ -0,0 +1,153 @@ +# Adding a gizmo to a node + +A gizmo is a draggable handle drawn on the canvas that edits one node input. This directory holds the +machinery for them. Adding one to a node usually means writing a table entry, not a file. + +## How it fits together + +``` +gizmo_registry.rs which parameters get gizmos, declared as data +generic_gizmos/ the mechanics: hit-testing, hover/drag, overlays, writing the input +gizmo_behaviors.rs the shape-specific half, and the only place node geometry belongs +gizmo_manager.rs picks the right handler for the selected layer +``` + +The generic layer always owns the hover/drag state machine, arbitration between overlapping gizmos, +cursor feedback, and the write to the graph. You supply what is genuinely particular to your node, and +often that is nothing at all. + +## The whole job, when the parameter is a length + +The Heart's radius is the smallest complete example. It is one entry and no code: + +```rust +const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: heart::RadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + behavior: GizmoBehavior::NONE, + position_hint: PositionHint::ParameterDerived, +}]; +``` + +Then register the node so the manager can find it: + +```rust +fn registered_gizmo_nodes() -> Vec<(ProtoNodeIdentifier, &'static [GizmoInfo])> { + vec![ + // ... + (generator_nodes::heart::IDENTIFIER, HEART_GIZMOS), + ] +} +``` + +That gives you a handle sitting `radius` out along the local +X axis, discoverable at rest, draggable, +clamped, undoable. If your parameter is a length measured from the layer's origin, stop here. + +### Which `gizmo_type` + +- `Slider` — an `f64`, dragged along a ray. The default and the one most parameters want. +- `Dial` — a `u32` count, stepped by horizontal drag. Sides, points, rows. +- `Angle` — an angle in degrees. Runs on the slider's machinery, so it expects a custom `drag`. +- `Position` — **not implemented.** Declaring it silently produces no gizmo. + +A declaration that supplies its own `drag` is hosted by the slider whatever it declares, because the +dial's step-drag is exactly what such a node is replacing. + +## When the default is not enough + +Everything below is optional and defaulted. Reach for a hook only when the default is wrong, and put +the function in `gizmo_behaviors.rs` rather than in the generic layer. + +| Hook | Use it when | +|---|---| +| `handle_positions` | the handle does not belong on the +X axis — a star's radius is grabbable at every vertex | +| `hover_distances` | what you grab is not a point — a grid's rows are grabbed anywhere along an edge | +| `drag` | reading a distance along a ray is the wrong question — a spiral winds, an arc sweeps | +| `snap_targets` | the drag should settle onto values derived from the node's other inputs | +| `overlay` | the shape draws something of its own: an outline, a guide, ticks | +| `draws_own_handle` | your overlay already draws the thing being grabbed, so the generic handle would double it | +| `angle_deadzone` | a rotational drag needs a jitter guard near the origin | + +A worked example, from `POLYGON_RADIUS`. A regular polygon's radius reaches every corner, so every +corner is a grab point: + +```rust +fn polygon_radius_handles(context: &GizmoContext, value: f64) -> Vec { + let Some((sides, _)) = extract_polygon_parameters(Some(context.layer), context.document) else { + return Vec::new(); + }; + + (0..sides) + .map(|vertex| { + let angle = ((vertex as f64) * TAU) / (sides as f64); + DVec2::new(value * angle.sin(), -value * angle.cos()) + }) + .collect() +} +``` + +The drag then runs along the ray through whichever corner was taken hold of, and `context.handle_index` +tells your overlay which one that is. + +### Writing a `drag` + +Return every input the motion implies, not just the one you declared. A spiral's turns cannot change +without its outer radius following, or the spiral tightens as it grows: + +```rust +fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites { + // ... read the starting values out of `drag.initial_parameters` + DragWrites::inputs(vec![ + (TurnsInput.into(), TaggedValue::F64(new_turns)), + (OuterRadiusInput.into(), TaggedValue::F64(new_outer_radius)), + ]) +} +``` + +Three things worth knowing about `DragInput`: + +- **It is mutable.** A gesture that reaches a limit and re-anchors rather than stopping — an arc dragged + past a full sweep hands over to its other endpoint — rewrites the baseline the rest of the drag is + measured against. +- **`initial_parameters` is the node as it was when the drag began.** Read from it, not from the + document: by the second frame the live values are the ones you already wrote. +- **`DragWrites` can carry a transform.** A control that repositions the shape as it resizes it needs + one; a grid grown from its top edge has to move up as it gains a row, or the edge slides out from + under the cursor. + +## The invariant + +A gizmo never mutates geometry. It writes a node input and re-runs the graph, then re-reads its own +position from the value it just wrote. Every edit path — gizmo, Properties panel, API — converges on the +same write, which is why a value changed in the panel moves the canvas handle for free. The grid's +transform is the one exception, and it moves the layer rather than the geometry. + +## Things that will catch you + +- **`INDEX` counts from the node's primary input**, so the first real parameter is `1`. Use the generated + symbol (`heart::RadiusInput::INDEX`) rather than a literal, and a node gaining an input will not + silently repoint your gizmo at the wrong one. +- **Respect the node's `#[hard(..)]` range.** Writing outside it does not clamp — it produces geometry the + renderer cannot draw. A heart with a cleavage deeper than its shoulders are high crosses its own lobes + and vanishes entirely. +- **A normalized parameter needs a `drag`.** The default writes a distance in document units straight + through, which is meaningless for a fraction-of-the-radius parameter. +- **The transform cage sits on top of the obvious grab points.** Its corner and edge handles land where a + circle's radius or an arc's endpoint invites the cursor, and it wins the press. Test away from them. +- **The bounding-box `PositionHint` variants are inert.** Every migrated shape derives its handle from a + parameter, so `BoundingBoxCenter` and friends currently fall through to the +X axis. +- **Nothing is drawn at rest unless something asks for it.** A slider with no overlay marks its grab + points; one that supplies an overlay is expected to draw its own resting state. + +## Testing + +Registry declarations are cheap to assert directly — see the tests at the bottom of `gizmo_registry.rs`, +which check that each node exposes what it should and that behaviors carrying handles or drags actually +have them. Pure helpers are worth extracting and testing on their own; `nearest_snap_target` in +`generic_slider_gizmo.rs` is the pattern. + +None of that catches a gizmo that is drawn in the wrong place or drags the wrong way. Run the editor and +grab the handle. Interaction code is exactly where tests pass and the control still feels wrong. diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs index 72011be231..88f81713d5 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -1,6 +1,6 @@ //! A generic, draggable handle that edits a continuous `f64` node parameter (e.g. a radius). //! -//! Unlike the hand-written shape gizmos in `shape_gizmos`, this gizmo is fully driven by data +//! Unlike the hand-written shape gizmos it replaced, this gizmo is fully driven by data //! from the [gizmo registry](crate::messages::tool::common_functionality::gizmos::gizmo_registry): //! it knows nothing about the specific node it edits beyond the node id, the parameter index, and //! the registry's [`GizmoInfo`]. This is what lets any node opt into a slider with zero custom code. diff --git a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs index 84babcf988..8c16c2ac58 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -1,7 +1,7 @@ //! # Generic Gizmos //! //! Data-driven, reusable gizmo components that any node can opt into via the -//! [gizmo registry](super::gizmo_registry). Where the legacy `shape_gizmos` each hand-code a +//! [gizmo registry](super::gizmo_registry). Where a hand-written handler used to hand-code a //! shape's interaction, the generic gizmos here are parameterized purely by `(node_id, //! parameter_index, GizmoInfo)` and therefore work for any node that registers them. //! diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs index c6c532bc41..e5f8de3e51 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -2,9 +2,11 @@ //! //! A data-driven lookup that maps node types to the parameters that should be exposed as //! interactive canvas gizmos. This is the foundation of the *generic* gizmo system: instead of -//! writing a bespoke handler for every shape (see the `shape_gizmos` module for the legacy, -//! hand-written handlers), a node simply declares which of its inputs are gizmo-enabled here and -//! the generic gizmo manager builds the appropriate interactive handles automatically. +//! writing a bespoke handler for every shape, a node declares which of its inputs are +//! gizmo-enabled here and the generic gizmo manager builds the interactive handles from that. +//! +//! See `README.md` in this directory for a guide to adding one, including the hooks a node reaches +//! for when the generic mechanics are not enough. //! //! To add gizmos to a new node: //! 1. Add a `const` slice of [`GizmoInfo`] describing its gizmo-enabled parameters.