diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index a7ab903dfe..fabac397c0 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::*; @@ -1382,6 +1382,189 @@ impl ShapeState { Some([(handles[0], start), (handles[1], end)]) } + /// Collect all affected points including those from selected segments + fn collect_affected_points(state: &SelectedLayerState, vector: &Vector) -> HashSet { + let mut affected_points = state.selected_points.clone(); + for (segment_id, _, start, end) in vector.segment_bezier_iter() { + if state.is_segment_selected(segment_id) { + affected_points.insert(ManipulatorPointId::Anchor(start)); + affected_points.insert(ManipulatorPointId::Anchor(end)); + } + } + affected_points + } + + /// Calculate the bounding box of all point positions + fn calculate_bounding_box(point_positions: &[(LayerNodeIdentifier, ManipulatorPointId, DVec2)]) -> [DVec2; 2] { + let min_x = point_positions.iter().map(|(_, _, pos)| pos.x).fold(f64::INFINITY, f64::min); + let max_x = point_positions.iter().map(|(_, _, pos)| pos.x).fold(f64::NEG_INFINITY, f64::max); + let min_y = point_positions.iter().map(|(_, _, pos)| pos.y).fold(f64::INFINITY, f64::min); + let max_y = point_positions.iter().map(|(_, _, pos)| pos.y).fold(f64::NEG_INFINITY, f64::max); + + [DVec2::new(min_x, min_y), DVec2::new(max_x, max_y)] + } + + /// Calculate the alignment target based on bounding box and aggregate type + fn calculate_alignment_target(combined_box: [DVec2; 2], aggregate: AlignAggregate) -> DVec2 { + match aggregate { + AlignAggregate::Min => combined_box[0], + AlignAggregate::Max => combined_box[1], + AlignAggregate::Center => (combined_box[0] + combined_box[1]) / 2., + } + } + + /// Calculate the delta for a point in document space + fn calculate_point_delta(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 = Self::collect_affected_points(state, &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)); + } + } + } + + if point_positions.is_empty() { + return; + } + + // Calculate bounding box and alignment target + let combined_box = Self::calculate_bounding_box(&point_positions); + let aggregated = Self::calculate_alignment_target(combined_box, aggregate); + + // 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_point_delta(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 35fd18e940..0c34a438a4 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; @@ -147,6 +148,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)] @@ -182,6 +187,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() @@ -343,31 +371,36 @@ impl LayoutHolder for PathTool { let _pin_pivot = pin_pivot_widget(self.tool_data.pivot_gizmo.pin_active(), false, PivotToolSource::Path); + // 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; + 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, - ], + widgets: vec![x_location, related_seperator.clone(), y_location, unrelated_seperator.clone()] + .into_iter() + .chain(self.alignment_widgets(alignment_disabled)) + .chain(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, + ]) + .collect(), }]) } } @@ -2966,6 +2999,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);