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..61e6b72171 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -0,0 +1,165 @@ +//! 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 horizontal drag distance into integer steps (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 horizontal_delta = input.mouse.position.x - drag_start.x; + let steps = (horizontal_delta / DIAL_PIXELS_PER_STEP).round() 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..ecb1306830 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -0,0 +1,194 @@ +//! 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::NodeInput; +use graph_craft::document::value::TaggedValue; +use graph_craft::document::NodeId; +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.), + } + } + + /// Detect hover by measuring the mouse's distance to the handle in viewport space. + /// 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 962e9a5d06..35b619e4b2 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -3,11 +3,13 @@ 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; 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 +34,10 @@ pub enum ShapeGizmoHandlers { Circle(CircleGizmoHandler), 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 { @@ -45,6 +51,8 @@ impl ShapeGizmoHandlers { Self::Circle(_) => "circle", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", + Self::Heart(_) => "heart", + Self::Generic(_) => "generic", Self::None => "none", } } @@ -58,6 +66,8 @@ 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::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -71,6 +81,8 @@ 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::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -84,6 +96,8 @@ 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::Generic(h) => h.handle_click(), Self::None => {} } } @@ -97,6 +111,8 @@ 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::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -110,6 +126,8 @@ impl ShapeGizmoHandlers { Self::Circle(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,6 +149,8 @@ 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::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -151,6 +171,8 @@ 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::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -163,6 +185,8 @@ 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::Generic(h) => h.mouse_cursor_icon(), Self::None => None, } } @@ -194,17 +218,17 @@ 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 + radius slider). 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() { 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() { @@ -214,6 +238,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/gizmos/gizmo_registry.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs new file mode 100644 index 0000000000..d7ff721b80 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -0,0 +1,279 @@ +//! # 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, +}]; + +const POLYGON_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: 1, + gizmo_type: GizmoType::Dial, + name: "Sides", + min: Some(3.), + max: None, + position_hint: PositionHint::BoundingBoxCenter, + }, + GizmoInfo { + parameter_index: 2, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + position_hint: PositionHint::ParameterDerived, + }, +]; + +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, + }, +]; + +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), + ] +} + +/// 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_a_sides_dial_and_radius_slider() { + let infos = get_gizmo_info(&generator_nodes::regular_polygon::IDENTIFIER); + assert_eq!(infos.len(), 2); + + let sides = infos.iter().find(|info| info.gizmo_type == GizmoType::Dial).expect("polygon should have a sides dial"); + assert_eq!(sides.parameter_index, 1); + assert_eq!(sides.min, Some(3.)); + + let radius = infos.iter().find(|info| info.gizmo_type == GizmoType::Slider).expect("polygon should have a radius slider"); + assert_eq!(radius.parameter_index, 2); + } + + #[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 all_six_existing_shapes_are_registered() { + assert_eq!(registered_gizmo_nodes().len(), 6); + 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; 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 266564c713..4d4f49e478 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -458,6 +458,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 b005f61a19..d79f591a30 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 5f0d6da016..426cddbce4 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 710b0def8d..7f18e307d2 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() } @@ -347,6 +354,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 { @@ -430,7 +438,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 @@ -1116,7 +1124,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 => { @@ -1139,6 +1147,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(), @@ -1150,7 +1159,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), @@ -1212,6 +1221,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), @@ -1480,13 +1490,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 e3abf2005a..2ecc693d1e 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -179,6 +179,110 @@ fn regular_polygon( List::new_from_element(Vector::from_subpath(subpath::Subpath::new_regular_polygon(DVec2::splat(-radius), points, radius))) } +/// 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, +) -> List { + 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(); + + 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. #[node_macro::node(category("Vector: Shape"))] fn star(