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_dial_gizmo.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs new file mode 100644 index 0000000000..f5b75db7fe --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_dial_gizmo.rs @@ -0,0 +1,200 @@ +//! 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::{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; +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::{GizmoContext, GizmoInfo, GizmoState}; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use glam::DVec2; +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. +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 +/// 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; + } + } + + /// 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 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, + handle_index: 0, + } + } + + 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 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; + } + + 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, _mouse_position: DVec2, 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.parameter()), + 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, 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; + } + + 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..88f81713d5 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/generic_slider_gizmo.rs @@ -0,0 +1,500 @@ +//! A generic, draggable handle that edits a continuous `f64` node parameter (e.g. a radius). +//! +//! 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. + +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_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::{DAffine2, DVec2}; +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. +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, + /// 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, + /// 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, + /// 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 { + 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., + snap_targets: Vec::new(), + handle_index: 0, + initial_parameters: Vec::new(), + previous_mouse_position: DVec2::ZERO, + total_angle: 0., + angle_delta: 0., + drag_origin: None, + } + } + + 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; + self.snap_targets.clear(); + self.initial_parameters.clear(); + self.total_angle = 0.; + self.drag_origin = None; + } + + /// Begin a drag if currently hovered. + pub fn handle_click(&mut self) { + if self.state == GenericSliderState::Hover { + self.state = GenericSliderState::Dragging; + } + } + + /// 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, + } + } + + /// 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, + document, + parameter: self.parameter(), + state: match self.state { + GenericSliderState::Inactive => GizmoState::Inactive, + GenericSliderState::Hover => GizmoState::Hover, + GenericSliderState::Dragging => GizmoState::Dragging, + }, + mouse_position, + shape_editor, + handle_index: self.handle_index, + } + } + + fn current_value(&self, document: &DocumentMessageHandler) -> Option { + 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. + /// + /// 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. + 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); + + self.hover_distances(document, value, mouse_position, viewport, center) + .into_iter() + .flatten() + .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)) + .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() + } + + /// 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 center = viewport.transform_point2(DVec2::ZERO); + + self.hover_distances(document, value, mouse_position, viewport, center) + .into_iter() + .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) + } + + /// 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, mouse_position: DVec2, 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; + 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(), + }; + 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; + self.snap_targets.clear(); + 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(&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, + 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), &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; + self.drag_origin = Some(drag_input.drag_start); + + if writes.is_empty() { + return; + } + 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; + } + + let viewport = document.metadata().transform_to_viewport(self.layer); + 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; 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); + + // 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. + 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)); + + 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, input.mouse.position, None), value) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(self.node_id, parameter), + input: NodeInput::value(coupled_value, false), + }); + } + } + + 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 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(self.previous_mouse_position - center) + .angle_to(inverse.transform_vector2(mouse_position - center)) + .to_degrees(); + + self.previous_mouse_position = mouse_position; + 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 + /// 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 { + 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, 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 { + // 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; + } + + // 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); + 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; + } + + overlay_context.line(center, handle, None, None); + 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), + GenericSliderState::Inactive => None, + } + } +} + +/// 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/generic_gizmos/mod.rs b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs new file mode 100644 index 0000000000..8c16c2ac58 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/generic_gizmos/mod.rs @@ -0,0 +1,285 @@ +//! # Generic Gizmos +//! +//! Data-driven, reusable gizmo components that any node can opt into via the +//! [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. +//! +//! - [`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 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()))?; + 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, mouse_position: DVec2, responses: &mut VecDeque) { + match self { + Self::Slider(g) => g.enter_hover(document, mouse_position, responses), + Self::Dial(g) => g.enter_hover(document, mouse_position, 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(&mut self, drag_start: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { + match self { + Self::Slider(g) => g.handle_update(drag_start, document, input, responses), + Self::Dial(g) => g.handle_update(drag_start, document, input, responses), + } + } + + fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) { + match self { + Self::Slider(g) => g.overlays(document, mouse_position, shape_editor, overlay_context), + Self::Dial(g) => g.overlays(document, mouse_position, shape_editor, 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 { + // `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 { + 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 => {} + } + } + + 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, mouse_position, 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 &mut 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, Some(shape_editor), 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, Some(shape_editor), 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_behaviors.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs new file mode 100644 index 0000000000..81ccd1d53c --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_behaviors.rs @@ -0,0 +1,1018 @@ +//! # 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::{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}; +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_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}; +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::{GridType, SpiralType, dvec2_to_point, get_line_endpoints}; +use kurbo::ParamCurveNearest; +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 { + snap_targets: None, + overlay: Some(star_sides_overlay), + coupled_writes: None, + handle_positions: None, + 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 +/// 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, + handle_positions: Some(star_radius_handles), + 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: Some(circular_radius_drag), + angle_deadzone: 0., + draws_own_handle: true, +}; + +/// 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., + draws_own_handle: true, +}; + +/// 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., + draws_own_handle: true, +}; + +/// 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), + 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. +pub const SPIRAL_TURNS: GizmoBehavior = GizmoBehavior { + snap_targets: None, + 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, + 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, + 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, + overlay: Some(polygon_sides_overlay), + coupled_writes: None, + handle_positions: None, + 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 +/// 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 +} + +/// 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) { + 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); + } +} + +/// 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), + _ => 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: &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)) = ( + initial_f64(drag, TurnsInput::INDEX), + initial_f64(drag, OuterRadiusInput::INDEX), + initial_f64(drag, InnerRadiusInput::INDEX), + initial_f64(drag, StartAngleInput::INDEX), + ) else { + return DragWrites::default(); + }; + let Some((spiral_type, ..)) = extract_spiral_parameters(context.layer, context.document) else { + return DragWrites::default(); + }; + + let growth_factor = calculate_growth_factor(initial_inner_radius, initial_turns, initial_outer_radius, spiral_type); + let turns_delta = spiral_swept_angle(drag) / 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 DragWrites::default(); + } + + // 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 + spiral_swept_angle(drag)))); + } + + DragWrites::inputs(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)); + } +} + +/// 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) -> 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 DragWrites::default(); + }; + let (Some(initial_start_angle), Some(initial_sweep_angle)) = (initial_f64(drag, StartAngleInput::INDEX), initial_f64(drag, SweepAngleInput::INDEX)) else { + 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| 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. + 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); +} + +/// 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)); + } +} + +/// 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)); + } +} + +/// 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); +} + +/// 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))]) +} + +/// 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))]) +} 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..f5fd7744fb 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -3,35 +3,28 @@ 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::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; 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 Polygon) +/// 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`, `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., `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, star) use the `Generic` variant. #[derive(Clone, Debug, Default)] pub enum ShapeGizmoHandlers { #[default] None, - Star(StarGizmoHandler), - Polygon(PolygonGizmoHandler), - Arc(ArcGizmoHandler), - Circle(CircleGizmoHandler), - 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), } impl ShapeGizmoHandlers { @@ -39,12 +32,7 @@ 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::Polygon(_) => "polygon", - Self::Arc(_) => "arc", - Self::Circle(_) => "circle", - Self::Grid(_) => "grid", - Self::Spiral(_) => "spiral", + Self::Generic(_) => "generic", Self::None => "none", } } @@ -52,12 +40,7 @@ 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::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::Generic(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -65,12 +48,7 @@ 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::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::Generic(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -78,12 +56,7 @@ 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::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::Generic(h) => h.handle_click(), Self::None => {} } } @@ -91,12 +64,7 @@ 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::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::Generic(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -104,12 +72,7 @@ impl ShapeGizmoHandlers { /// Cleans up any state used by the gizmo handler. pub fn cleanup(&mut self) { match self { - 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::Generic(h) => h.cleanup(), Self::None => {} } } @@ -125,12 +88,7 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { 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::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::Generic(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -145,24 +103,14 @@ impl ShapeGizmoHandlers { overlay_context: &mut OverlayContext, ) { 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::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::Generic(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } 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::Circle(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, } } @@ -191,28 +139,36 @@ 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 + // 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 + // 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 + // 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 + // 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). 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() { + return GenericGizmoManager::detect_gizmos(layer, document).map(ShapeGizmoHandlers::Generic); } 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..e5f8de3e51 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_registry.rs @@ -0,0 +1,490 @@ +//! # 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, 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. +//! 2. Register the node's [`ProtoNodeIdentifier`] in [`registered_gizmo_nodes`]. +//! +//! 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 crate::messages::tool::common_functionality::gizmos::gizmo_behaviors; +use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use glam::{DAffine2, DVec2}; +use graph_craft::ProtoNodeIdentifier; +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. +#[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 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, +} + +/// 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, +} + +/// 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>, + /// Which of the parameter's grab points is in play, indexing the list from `handle_positions`. + 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, + /// 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, +} + +/// 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() + } +} + +/// 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 +/// 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, + /// 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, + /// 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, + /// 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, + /// 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. + /// + /// 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, + /// 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, + /// 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 { + /// 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, + handle_positions: None, + hover_distances: None, + drag: None, + angle_deadzone: 0., + draws_own_handle: false, + }; +} + +/// 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)] +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, + /// Shape-specific behavior, for the few nodes whose gizmo needs more than the generic mechanics. + pub behavior: GizmoBehavior, +} + +// --- Per-node gizmo declarations ------------------------------------------------------------ + +const CIRCLE_GIZMOS: &[GizmoInfo] = &[GizmoInfo { + parameter_index: circle::RadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: None, + behavior: gizmo_behaviors::CIRCULAR_RADIUS, + 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: 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 { + parameter_index: star::SidesInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Points", + min: Some(3.), + max: None, + behavior: gizmo_behaviors::STAR_SIDES, + position_hint: PositionHint::BoundingBoxCenter, + }, + GizmoInfo { + parameter_index: star::Radius1Input::INDEX, + gizmo_type: GizmoType::Slider, + name: "Outer Radius", + min: Some(0.), + max: None, + behavior: gizmo_behaviors::STAR_RADIUS, + position_hint: PositionHint::ParameterDerived, + }, + GizmoInfo { + parameter_index: star::Radius2Input::INDEX, + gizmo_type: GizmoType::Slider, + name: "Inner Radius", + min: Some(0.), + max: None, + behavior: gizmo_behaviors::STAR_RADIUS, + position_hint: PositionHint::ParameterDerived, + }, +]; + +const ARC_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: arc::RadiusInput::INDEX, + gizmo_type: GizmoType::Slider, + name: "Radius", + min: Some(0.), + max: 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 + // 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", + min: Some(0.), + max: Some(360.), + behavior: gizmo_behaviors::ARC_SWEEP, + position_hint: PositionHint::ParameterDerived, + }, +]; + +// 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. +// 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. +const GRID_GIZMOS: &[GizmoInfo] = &[ + GizmoInfo { + parameter_index: grid::ColumnsInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Columns", + min: Some(1.), + max: None, + behavior: gizmo_behaviors::GRID_COLUMNS, + position_hint: PositionHint::BoundingBoxCorner, + }, + GizmoInfo { + parameter_index: grid::RowsInput::INDEX, + gizmo_type: GizmoType::Dial, + name: "Rows", + min: Some(1.), + max: None, + behavior: gizmo_behaviors::GRID_ROWS, + 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_a_sides_dial_and_a_radius() { + let infos = get_gizmo_info(&generator_nodes::regular_polygon::IDENTIFIER); + 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 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] + 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_radius_cleavage_and_shoulder() { + let infos = get_gizmo_info(&generator_nodes::heart::IDENTIFIER); + 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] + 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..01bdcfcb37 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_behaviors; pub mod gizmo_manager; -pub mod shape_gizmos; +pub mod gizmo_registry; 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/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 585257fef5..0000000000 --- a/editor/src/messages/tool/common_functionality/gizmos/shape_gizmos/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -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/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/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/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/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; 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; 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] + } +} 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..6a8e5813ec --- /dev/null +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -0,0 +1,70 @@ +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::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::shapes::shape_utility::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; + +/// 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; + +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, graphene_std::vector::generator_nodes::heart::RadiusInput), + 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/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; 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/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)?; 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; diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index eda939a304..3711efbdff 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 @@ -569,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; }; @@ -1088,7 +1104,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 +1127,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 +1139,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 +1203,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 +1472,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/libraries/vector-types/src/vector/algorithms/shapes.rs b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs index 33c6265cee..63fb23dd0e 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) @@ -343,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); + } +} diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index a77b5c98eb..7a9e835393 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -172,6 +172,85 @@ 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: Item, + /// How far the top V dips below the upper bound of the heart. + #[default(0.2)] + #[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] + #[hard(0..89)] + cleavage_angle: Item, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + #[default(0.55)] + #[range] + #[hard(0..1.2)] + lobe_fullness: Item, + /// Vertical position of the side anchor (positive raises the shoulder). + #[default(0.5)] + #[range] + #[hard(-0.5..0.9)] + shoulder_height: Item, + /// Horizontal position of the side anchor. + #[default(1.)] + #[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] + #[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] + #[hard(0..1.2)] + upper_curvature: Item, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + #[default(1.)] + #[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] + #[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] + #[hard(0..1.2)] + taper_length: Item, +) -> Item { + 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. #[node_macro::node(category("Vector: Shape"))] fn star(