From 5cf851488d15ab0e8951a796ca8c91965c8c38f6 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:11:51 +0530 Subject: [PATCH 01/10] Feat allign functionality for path tool across x and y axis --- .../tool/common_functionality/shape_editor.rs | 156 +++++++++++++++++- .../messages/tool/tool_messages/path_tool.rs | 91 +++++++--- 2 files changed, 222 insertions(+), 25 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 0183d65cc3..3a7566a45d 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::*; @@ -1450,6 +1450,160 @@ impl ShapeState { Some([(handles[0], start), (handles[1], end)]) } + /// 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 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)); + } + } + + // 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 of all selected points + 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); + + let combined_box = [DVec2::new(min_x, min_y), DVec2::new(max_x, max_y)]; + + // Calculate the alignment target + let aggregated = match aggregate { + AlignAggregate::Min => combined_box[0], + AlignAggregate::Max => combined_box[1], + AlignAggregate::Center => (combined_box[0] + combined_box[1]) / 2., + }; + + // 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 { + // Calculate translation in viewport space, only along the specified axis + let translation_viewport = (aggregated - viewport_pos) * axis_vec; + + // Convert translation to document space + let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + let transform_to_document = transform_to_viewport.inverse(); + + // Transform the delta + let delta = transform_to_document.transform_vector2(translation_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 }; + + // 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), + _ => continue, + }; + + // Find the anchor this handle is attached to + let Some((_, _, start_point, end_point)) = vector.segment_bezier_iter().find(|(id, _, _, _)| *id == segment_id) else { + continue; + }; + 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 { + continue; + }; + + // 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_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + 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 }); + } + } + /// 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 80f0a6faa4..888d16ea12 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -13,6 +13,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; @@ -154,6 +155,10 @@ pub enum PathToolMessage { Duplicate, TogglePointEditing, ToggleSegmentEditing, + AlignSelectedAnchors { + axis: AlignAxis, + aggregate: AlignAggregate, + }, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -191,6 +196,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::AlignSelectedAnchors { axis, aggregate }.into()) + .disabled(disabled) + .widget_instance() + }) + } +} + impl ToolMetadata for PathTool { fn icon_name(&self) -> String { "VectorPathTool".into() @@ -352,30 +380,37 @@ impl LayoutHolder for PathTool { let _pin_pivot = pin_pivot_widget(self.tool_data.pivot_gizmo.pin_active(), false, PivotToolSource::Path); - Layout(vec![LayoutGroup::row(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; + + Layout(vec![LayoutGroup::Row { + 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(), + }]) } } @@ -3018,6 +3053,14 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Ready } + (_, PathToolMessage::AlignSelectedAnchors { 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); From 821de8292896b8c96bebeef0f01b01c98b45ce8d Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:29:37 +0530 Subject: [PATCH 02/10] rename path tool message to AlignSelectedManipulatorPoints --- editor/src/messages/tool/tool_messages/path_tool.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 888d16ea12..8e32c86223 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -155,7 +155,7 @@ pub enum PathToolMessage { Duplicate, TogglePointEditing, ToggleSegmentEditing, - AlignSelectedAnchors { + AlignSelectedManipulatorPoints { axis: AlignAxis, aggregate: AlignAggregate, }, @@ -212,7 +212,7 @@ impl PathTool { }; IconButton::new(icon, 24) .tooltip_label(label) - .on_update(move |_| PathToolMessage::AlignSelectedAnchors { axis, aggregate }.into()) + .on_update(move |_| PathToolMessage::AlignSelectedManipulatorPoints { axis, aggregate }.into()) .disabled(disabled) .widget_instance() }) @@ -3053,7 +3053,7 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Ready } - (_, PathToolMessage::AlignSelectedAnchors { axis, aggregate }) => { + (_, PathToolMessage::AlignSelectedManipulatorPoints { axis, aggregate }) => { responses.add(DocumentMessage::AddTransaction); shape_editor.align_selected_points(document, responses, axis, aggregate); responses.add(DocumentMessage::EndTransaction); From 689676c4bb1ed18a900883807a66b3250cb7863d Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:41:50 +0530 Subject: [PATCH 03/10] abstract helper functions --- .../tool/common_functionality/shape_editor.rs | 196 ++++++++++-------- 1 file changed, 115 insertions(+), 81 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 3a7566a45d..52521c6448 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1450,6 +1450,115 @@ 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 @@ -1466,13 +1575,7 @@ impl ShapeState { let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); // Include points from selected segments - 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)); - } - } + let affected_points = Self::collect_affected_points(state, &vector); // Collect positions for &point in affected_points.iter() { @@ -1487,20 +1590,9 @@ impl ShapeState { return; } - // Calculate bounding box of all selected points - 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); - - let combined_box = [DVec2::new(min_x, min_y), DVec2::new(max_x, max_y)]; - - // Calculate the alignment target - let aggregated = match aggregate { - AlignAggregate::Min => combined_box[0], - AlignAggregate::Max => combined_box[1], - AlignAggregate::Center => (combined_box[0] + combined_box[1]) / 2., - }; + // 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 @@ -1509,15 +1601,8 @@ impl ShapeState { let mut handle_movements = Vec::new(); for (layer, point, viewport_pos) in point_positions { - // Calculate translation in viewport space, only along the specified axis - let translation_viewport = (aggregated - viewport_pos) * axis_vec; - - // Convert translation to document space let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); - let transform_to_document = transform_to_viewport.inverse(); - - // Transform the delta - let delta = transform_to_document.transform_vector2(translation_viewport); + let delta = Self::calculate_point_delta(viewport_pos, aggregated, axis_vec, transform_to_viewport); match point { ManipulatorPointId::Anchor(point_id) => { @@ -1547,60 +1632,9 @@ impl ShapeState { // 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 }; - - // 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), - _ => continue, - }; - - // Find the anchor this handle is attached to - let Some((_, _, start_point, end_point)) = vector.segment_bezier_iter().find(|(id, _, _, _)| *id == segment_id) else { - continue; - }; - 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 { - continue; - }; - - // 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_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); - 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 }); + Self::process_handle_alignment(layer, point, original_viewport_pos, aggregated, axis_vec, &anchor_deltas, &vector, transform_to_viewport, responses); } } From 4cbb1ad6a34bf78b6c40347f9c928c60e635fb71 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 11 Jan 2026 01:38:06 +0530 Subject: [PATCH 04/10] formating issues --- .../src/messages/tool/common_functionality/shape_editor.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 52521c6448..80b6b6f891 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1482,12 +1482,7 @@ impl ShapeState { } /// 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 { + 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) From fe12209149832677374560f032707c4dd42fc9f7 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Fri, 30 Jan 2026 03:05:10 +0530 Subject: [PATCH 05/10] use Rect::point_iter instead of bounding box function --- .../tool/common_functionality/shape_editor.rs | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 80b6b6f891..d1cd475123 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1462,16 +1462,6 @@ impl ShapeState { 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 { @@ -1581,13 +1571,11 @@ impl ShapeState { } } - 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); + let Some(combined_box) = graphene_std::renderer::Rect::point_iter(point_positions.iter().map(|(_, _, pos)| *pos)) else { + return; + }; + let aggregated = Self::calculate_alignment_target(combined_box.0, 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 From 079b04ef974045568e47121df45728578b061a17 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 00:05:06 +0530 Subject: [PATCH 06/10] Deduplicate alignment target and affected points logic into shared methods --- .../document/document_message_handler.rs | 14 ++----- .../portfolio/document/utility_types/misc.rs | 11 ++++++ .../document/utility_types/transformation.rs | 15 +------- .../tool/common_functionality/shape_editor.rs | 37 +++++++------------ 4 files changed, 30 insertions(+), 47 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 8cdb18e959..5e3e7688bf 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -16,7 +16,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::utility_types::{PanelType, PersistentData}; use crate::messages::prelude::*; @@ -280,22 +280,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 7ea2b5dc21..2562c3e9cf 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -28,6 +28,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/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs index 6ccd9f83f0..8cc8164bbe 100644 --- a/editor/src/messages/portfolio/document/utility_types/transformation.rs +++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs @@ -84,21 +84,10 @@ impl OriginalTransforms { let Some(vector) = network_interface.compute_modified_vector(layer) else { continue; }; - let Some(selected_points) = shape_editor.selected_points_in_layer(layer) else { + let Some(state) = shape_editor.selected_shape_state.get(&layer) else { continue; }; - let Some(selected_segments) = shape_editor.selected_segments_in_layer(layer) else { - continue; - }; - - let mut selected_points = selected_points.clone(); - - for (segment_id, _, start, end) in vector.segment_bezier_iter() { - if selected_segments.contains(&segment_id) { - selected_points.insert(ManipulatorPointId::Anchor(start)); - selected_points.insert(ManipulatorPointId::Anchor(end)); - } - } + let selected_points = state.affected_points(&vector); // Anchors also move their handles let anchor_ids = selected_points.iter().filter_map(|point| point.as_anchor()); diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index d1cd475123..3defd4798f 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -136,6 +136,18 @@ impl SelectedLayerState { } } + /// Returns selected points plus the anchor endpoints of any selected segments. + pub fn affected_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,27 +1462,6 @@ 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 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; @@ -1560,7 +1551,7 @@ impl ShapeState { 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); + let affected_points = state.affected_points(&vector); // Collect positions for &point in affected_points.iter() { @@ -1575,7 +1566,7 @@ impl ShapeState { let Some(combined_box) = graphene_std::renderer::Rect::point_iter(point_positions.iter().map(|(_, _, pos)| *pos)) else { return; }; - let aggregated = Self::calculate_alignment_target(combined_box.0, aggregate); + 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 From 56d761842b1070c076cb4bbf0b48e020de359153 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 00:28:17 +0530 Subject: [PATCH 07/10] Revert transformation.rs refactoring to keep it out of this PR's scope --- .../document/utility_types/transformation.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs index 8cc8164bbe..6ccd9f83f0 100644 --- a/editor/src/messages/portfolio/document/utility_types/transformation.rs +++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs @@ -84,10 +84,21 @@ impl OriginalTransforms { let Some(vector) = network_interface.compute_modified_vector(layer) else { continue; }; - let Some(state) = shape_editor.selected_shape_state.get(&layer) else { + let Some(selected_points) = shape_editor.selected_points_in_layer(layer) else { continue; }; - let selected_points = state.affected_points(&vector); + let Some(selected_segments) = shape_editor.selected_segments_in_layer(layer) else { + continue; + }; + + let mut selected_points = selected_points.clone(); + + for (segment_id, _, start, end) in vector.segment_bezier_iter() { + if selected_segments.contains(&segment_id) { + selected_points.insert(ManipulatorPointId::Anchor(start)); + selected_points.insert(ManipulatorPointId::Anchor(end)); + } + } // Anchors also move their handles let anchor_ids = selected_points.iter().filter_map(|point| point.as_anchor()); From 2b0b7ee32951a20c294c8c2e41b2056e06df5852 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 09:08:02 +0530 Subject: [PATCH 08/10] Refactor path tool widget layout to use extend pattern --- .../messages/tool/tool_messages/path_tool.rs | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 8e32c86223..5fb192ba15 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -384,33 +384,31 @@ impl LayoutHolder for PathTool { 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()] - .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(), - }]) + 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 }]) } } From 077e845dc8f737921ed0ae63018c539cb7fc66be Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 09:33:49 +0530 Subject: [PATCH 09/10] make function names more descriptive --- .../messages/tool/common_functionality/shape_editor.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 3defd4798f..9fef240d35 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -137,7 +137,7 @@ impl SelectedLayerState { } /// Returns selected points plus the anchor endpoints of any selected segments. - pub fn affected_points(&self, vector: &Vector) -> HashSet { + 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) { @@ -1463,7 +1463,7 @@ impl ShapeState { } /// 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 { + 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) @@ -1551,7 +1551,7 @@ impl ShapeState { 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.affected_points(&vector); + let affected_points = state.selected_and_segment_anchor_points(&vector); // Collect positions for &point in affected_points.iter() { @@ -1576,7 +1576,7 @@ impl ShapeState { 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); + let delta = Self::calculate_alignment_delta_in_document_space(viewport_pos, aggregated, axis_vec, transform_to_viewport); match point { ManipulatorPointId::Anchor(point_id) => { From b6c8518d2cc5d450acd510249d8ad1e3659b49fd Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Fri, 20 Mar 2026 10:04:17 +0530 Subject: [PATCH 10/10] bug fixes after rebase --- editor/src/messages/tool/tool_messages/path_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 5fb192ba15..6770841af4 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -408,7 +408,7 @@ impl LayoutHolder for PathTool { // pin_pivot, ]); - Layout(vec![LayoutGroup::Row { widgets }]) + Layout(vec![LayoutGroup::row(widgets)]) } }