diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 330fe16abc..711ea5658a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -17,7 +17,7 @@ use crate::messages::portfolio::document::overlays::grid_overlays::{grid_overlay use crate::messages::portfolio::document::overlays::utility_types::{OverlaysType, OverlaysVisibilitySettings, Pivot}; use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; -use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ}; +use crate::messages::portfolio::document::utility_types::misc::{AlignAxis, FlipAxis, PTZ}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate}; use crate::messages::portfolio::document::utility_types::nodes::RawBuffer; use crate::messages::portfolio::utility_types::{FontCatalog, PanelType, PersistentData}; @@ -282,22 +282,14 @@ impl MessageHandler> for DocumentMes return; }; - let aggregated = match aggregate { - AlignAggregate::Min => combined_box[0], - AlignAggregate::Max => combined_box[1], - AlignAggregate::Center => (combined_box[0] + combined_box[1]) / 2., - }; + let aggregated = aggregate.target_position(combined_box); let mut added_transaction = false; for layer in self.network_interface.selected_nodes().selected_unlocked_layers(&self.network_interface) { let Some(bbox) = self.metadata().bounding_box_viewport(layer) else { continue; }; - let center = match aggregate { - AlignAggregate::Min => bbox[0], - AlignAggregate::Max => bbox[1], - _ => (bbox[0] + bbox[1]) / 2., - }; + let center = aggregate.target_position(bbox); let translation = (aggregated - center) * axis; if !added_transaction { responses.add(DocumentMessage::AddTransaction); diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index c474bf9665..b5c28aeb5c 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -26,6 +26,17 @@ pub enum AlignAggregate { Center, } +impl AlignAggregate { + /// Given a bounding box `[min, max]`, returns the alignment target position. + pub fn target_position(self, bbox: [DVec2; 2]) -> DVec2 { + match self { + AlignAggregate::Min => bbox[0], + AlignAggregate::Max => bbox[1], + AlignAggregate::Center => (bbox[0] + bbox[1]) / 2., + } + } +} + // #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] // pub enum DocumentMode { // #[default] diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index f102bbb56f..e745f489cb 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -4,7 +4,7 @@ use super::utility_functions::{adjust_handle_colinearity, calculate_segment_angl use crate::consts::HANDLE_LENGTH_FACTOR; use crate::messages::portfolio::document::overlays::utility_functions::selected_segments_for_layer; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; -use crate::messages::portfolio::document::utility_types::misc::{PathSnapSource, SnapSource}; +use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, PathSnapSource, SnapSource}; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; @@ -136,6 +136,18 @@ impl SelectedLayerState { } } + /// Returns selected points plus the anchor endpoints of any selected segments. + pub fn selected_and_segment_anchor_points(&self, vector: &Vector) -> HashSet { + let mut affected_points = self.selected_points.clone(); + for (segment_id, _, start, end) in vector.segment_bezier_iter() { + if self.selected_segments.contains(&segment_id) { + affected_points.insert(ManipulatorPointId::Anchor(start)); + affected_points.insert(ManipulatorPointId::Anchor(end)); + } + } + affected_points + } + pub fn ignore_anchors(&mut self, status: bool) { if self.ignore_anchors != status { return; @@ -1450,6 +1462,156 @@ impl ShapeState { Some([(handles[0], start), (handles[1], end)]) } + /// Calculate the delta for a point in document space + fn calculate_alignment_delta_in_document_space(viewport_pos: DVec2, aggregated: DVec2, axis_vec: DVec2, transform_to_viewport: DAffine2) -> DVec2 { + let translation_viewport = (aggregated - viewport_pos) * axis_vec; + let transform_to_document = transform_to_viewport.inverse(); + transform_to_document.transform_vector2(translation_viewport) + } + + /// Process handle alignment and generate the modification message + fn process_handle_alignment( + layer: LayerNodeIdentifier, + point: ManipulatorPointId, + original_viewport_pos: DVec2, + aggregated: DVec2, + axis_vec: DVec2, + anchor_deltas: &std::collections::HashMap<(LayerNodeIdentifier, PointId), DVec2>, + vector: &Vector, + transform_to_viewport: DAffine2, + responses: &mut VecDeque, + ) { + // Get the handle's segment and anchor info + let (segment_id, is_primary) = match point { + ManipulatorPointId::PrimaryHandle(seg_id) => (seg_id, true), + ManipulatorPointId::EndHandle(seg_id) => (seg_id, false), + _ => return, + }; + + // Find the anchor this handle is attached to + let Some((_, _, start_point, end_point)) = vector.segment_bezier_iter().find(|(id, _, _, _)| *id == segment_id) else { + return; + }; + let anchor_id = if is_primary { start_point } else { end_point }; + + // Get the anchor's ORIGINAL position (before movements) + let Some(anchor_position_original) = ManipulatorPointId::Anchor(anchor_id).get_position(vector) else { + return; + }; + + // Calculate the anchor's NEW position by applying the delta we calculated earlier + let anchor_delta = anchor_deltas.get(&(layer, anchor_id)).copied().unwrap_or(DVec2::ZERO); + let anchor_position_new = anchor_position_original + anchor_delta; + + // Calculate the target position for the handle + let transform_to_document = transform_to_viewport.inverse(); + + // The handle should move to the aggregated target, just like anchors do + // Calculate target position in viewport space (only moving along the axis) + let target_viewport = DVec2::new( + if axis_vec.x > 0.5 { aggregated.x } else { original_viewport_pos.x }, + if axis_vec.y > 0.5 { aggregated.y } else { original_viewport_pos.y }, + ); + + // Convert target to document space + let target_document = transform_to_document.transform_point2(target_viewport); + + // Calculate handle position RELATIVE to its anchor's NEW position + let relative_position = target_document - anchor_position_new; + + // Set the handle to the calculated position + let modification_type = if is_primary { + VectorModificationType::SetPrimaryHandle { + segment: segment_id, + relative_position, + } + } else { + VectorModificationType::SetEndHandle { + segment: segment_id, + relative_position, + } + }; + + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + } + + /// Align the selected points based on axis and aggregate. + pub fn align_selected_points(&self, document: &DocumentMessageHandler, responses: &mut VecDeque, axis: AlignAxis, aggregate: AlignAggregate) { + // Convert axis to direction vector + let axis_vec = match axis { + AlignAxis::X => DVec2::X, + AlignAxis::Y => DVec2::Y, + }; + + // Collect all selected points with their positions in viewport space + let mut point_positions = Vec::new(); + + for (&layer, state) in &self.selected_shape_state { + let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; + let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + + // Include points from selected segments + let affected_points = state.selected_and_segment_anchor_points(&vector); + + // Collect positions + for &point in affected_points.iter() { + if let Some(position) = point.get_position(&vector) { + let viewport_pos = transform_to_viewport.transform_point2(position); + point_positions.push((layer, point, viewport_pos)); + } + } + } + + // Calculate bounding box and alignment target + let Some(combined_box) = graphene_std::renderer::Rect::point_iter(point_positions.iter().map(|(_, _, pos)| *pos)) else { + return; + }; + let aggregated = aggregate.target_position(combined_box.0); + + // Separate anchor and handle movements + // We apply anchor movements first, then handle movements matches the behavior of Scale (S shortcut) when scaling to 0 + let mut anchor_movements = Vec::new(); + let mut anchor_deltas = std::collections::HashMap::new(); + let mut handle_movements = Vec::new(); + + for (layer, point, viewport_pos) in point_positions { + let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + let delta = Self::calculate_alignment_delta_in_document_space(viewport_pos, aggregated, axis_vec, transform_to_viewport); + + match point { + ManipulatorPointId::Anchor(point_id) => { + anchor_movements.push((layer, VectorModificationType::ApplyPointDelta { point: point_id, delta })); + anchor_deltas.insert((layer, point_id), delta); + } + ManipulatorPointId::PrimaryHandle(_) | ManipulatorPointId::EndHandle(_) => { + handle_movements.push((layer, point, viewport_pos)); + } + } + } + + // Apply anchor movements first + for (layer, modification_type) in anchor_movements { + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + } + + // TODO: figure out this Special case: When exactly 2 anchors are selected, skip handle transformations + let selected_anchor_count = anchor_deltas.len(); + if selected_anchor_count == 2 { + return; + } + + // Process handle movements + // We need to manually calculate the anchor's NEW position (original + delta) + // because compute_modified_vector() hasn't applied the anchor movements yet + // This matches the behavior of Scale (S) when scaling to 0 + for (layer, point, original_viewport_pos) in handle_movements { + let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; + let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + + Self::process_handle_alignment(layer, point, original_viewport_pos, aggregated, axis_vec, &anchor_deltas, &vector, transform_to_viewport, responses); + } + } + /// Dissolve the selected points. pub fn delete_selected_points(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque, start_transaction: bool) { let mut transaction_started = false; diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 542a6f0a8c..a3a7ed2f28 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -12,6 +12,7 @@ use crate::messages::portfolio::document::overlays::utility_functions::{path_ove use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, OverlayContext}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis}; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::transformation::Axis; use crate::messages::preferences::SelectionMode; @@ -152,6 +153,10 @@ pub enum PathToolMessage { Duplicate, TogglePointEditing, ToggleSegmentEditing, + AlignSelectedManipulatorPoints { + axis: AlignAxis, + aggregate: AlignAggregate, + }, } #[derive(PartialEq, Eq, Hash, Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)] @@ -187,6 +192,29 @@ pub enum PathOptionsUpdate { TogglePivotPinned, } +impl PathTool { + fn alignment_widgets(&self, disabled: bool) -> impl Iterator + use<> { + [AlignAxis::X, AlignAxis::Y] + .into_iter() + .flat_map(|axis| [(axis, AlignAggregate::Min), (axis, AlignAggregate::Center), (axis, AlignAggregate::Max)]) + .map(move |(axis, aggregate)| { + let (icon, label) = match (axis, aggregate) { + (AlignAxis::X, AlignAggregate::Min) => ("AlignLeft", "Align Left"), + (AlignAxis::X, AlignAggregate::Center) => ("AlignHorizontalCenter", "Align Horizontal Center"), + (AlignAxis::X, AlignAggregate::Max) => ("AlignRight", "Align Right"), + (AlignAxis::Y, AlignAggregate::Min) => ("AlignTop", "Align Top"), + (AlignAxis::Y, AlignAggregate::Center) => ("AlignVerticalCenter", "Align Vertical Center"), + (AlignAxis::Y, AlignAggregate::Max) => ("AlignBottom", "Align Bottom"), + }; + IconButton::new(icon, 24) + .tooltip_label(label) + .on_update(move |_| PathToolMessage::AlignSelectedManipulatorPoints { axis, aggregate }.into()) + .disabled(disabled) + .widget_instance() + }) + } +} + impl ToolMetadata for PathTool { fn icon_name(&self) -> String { "VectorPathTool".into() @@ -348,32 +376,35 @@ impl LayoutHolder for PathTool { let _pin_pivot = pin_pivot_widget(self.tool_data.pivot_gizmo.pin_active(), false, PivotToolSource::Path); - Layout(vec![LayoutGroup::Row { - widgets: vec![ - x_location, - related_seperator.clone(), - y_location, - unrelated_seperator.clone(), - colinear_handle_checkbox, - related_seperator.clone(), - colinear_handles_label, - unrelated_seperator.clone(), - point_editing_mode, - related_seperator.clone(), - segment_editing_mode, - unrelated_seperator.clone(), - path_overlay_mode_widget, - unrelated_seperator.clone(), - path_node_button, - // checkbox.clone(), - // related_seperator.clone(), - // dropdown.clone(), - // unrelated_seperator, - // pivot_reference, - // related_seperator.clone(), - // pin_pivot, - ], - }]) + // Determine if alignment buttons should be disabled (need 2+ points selected) + let multiple_points_selected = matches!(self.tool_data.selection_status, SelectionStatus::Multiple(_)); + let alignment_disabled = !multiple_points_selected; + + let mut widgets = vec![x_location, related_seperator.clone(), y_location, unrelated_seperator.clone()]; + widgets.extend(self.alignment_widgets(alignment_disabled)); + widgets.extend(vec![ + unrelated_seperator.clone(), + colinear_handle_checkbox, + related_seperator.clone(), + colinear_handles_label, + unrelated_seperator.clone(), + point_editing_mode, + related_seperator.clone(), + segment_editing_mode, + unrelated_seperator.clone(), + path_overlay_mode_widget, + unrelated_seperator.clone(), + path_node_button, + // checkbox.clone(), + // related_seperator.clone(), + // dropdown.clone(), + // unrelated_seperator, + // pivot_reference, + // related_seperator.clone(), + // pin_pivot, + ]); + + Layout(vec![LayoutGroup::Row { widgets }]) } } @@ -3025,6 +3056,14 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Ready } + (_, PathToolMessage::AlignSelectedManipulatorPoints { axis, aggregate }) => { + responses.add(DocumentMessage::AddTransaction); + shape_editor.align_selected_points(document, responses, axis, aggregate); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + + PathToolFsmState::Ready + } (_, PathToolMessage::DoubleClick { extend_selection, shrink_selection }) => { // Double-clicked on a point (flip smooth/sharp behavior) let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);