From 4a3035336214fedb6a99c6c51ac1776503e0a79c Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:11:51 +0530 Subject: [PATCH 1/9] 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 | 48 +++++- 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index a7ab903dfe..1245cb06cd 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,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 35fd18e940..e370263455 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, + AlignSelectedAnchors { + 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::AlignSelectedAnchors { axis, aggregate }.into()) + .disabled(disabled) + .widget_instance() + }) + } +} + impl ToolMetadata for PathTool { fn icon_name(&self) -> String { "VectorPathTool".into() @@ -343,12 +371,21 @@ 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(), + ] + .into_iter() + .chain(self.alignment_widgets(alignment_disabled)) + .chain(vec![ + unrelated_seperator.clone(), colinear_handle_checkbox, related_seperator.clone(), colinear_handles_label, @@ -367,7 +404,8 @@ impl LayoutHolder for PathTool { // pivot_reference, // related_seperator.clone(), // pin_pivot, - ], + ]) + .collect(), }]) } } @@ -2966,6 +3004,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 75c0552f2edd331980196b64cd384401da0692fe Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:29:37 +0530 Subject: [PATCH 2/9] 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 e370263455..85c97edb65 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -148,7 +148,7 @@ pub enum PathToolMessage { Duplicate, TogglePointEditing, ToggleSegmentEditing, - AlignSelectedAnchors { + AlignSelectedManipulatorPoints { axis: AlignAxis, aggregate: AlignAggregate, }, @@ -203,7 +203,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() }) @@ -3004,7 +3004,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 dbd11bf1bfd64da17f57b324abf891fda01b9836 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sat, 10 Jan 2026 13:41:50 +0530 Subject: [PATCH 3/9] 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 1245cb06cd..fceb60f53e 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1382,6 +1382,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 @@ -1398,13 +1507,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() { @@ -1419,20 +1522,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 @@ -1441,15 +1533,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) => { @@ -1479,60 +1564,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 f33939ee6baefa70384425d4ec0d024261d27f78 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Sun, 11 Jan 2026 01:38:06 +0530 Subject: [PATCH 4/9] formating issues --- .../tool/common_functionality/shape_editor.rs | 7 +-- .../messages/tool/tool_messages/path_tool.rs | 55 +++++++++---------- 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index fceb60f53e..fabac397c0 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1414,12 +1414,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) diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 85c97edb65..0c34a438a4 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -376,36 +376,31 @@ impl LayoutHolder for PathTool { 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(), + 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(), }]) } } From e1475a2a29e9793217cbe2ae284a68e1fc727850 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Fri, 30 Jan 2026 03:05:10 +0530 Subject: [PATCH 5/9] 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 fabac397c0..0da04b61af 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1394,16 +1394,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 { @@ -1513,13 +1503,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 4c6e365f0b597713015915f3dd3fa2a062f6649d Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 00:05:06 +0530 Subject: [PATCH 6/9] 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 6402f609fd..48d012f292 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::document::utility_types::nodes::RawBuffer; use crate::messages::portfolio::utility_types::{FontCatalog, PanelType, PersistentData}; @@ -281,22 +281,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/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs index 63e1b8baa8..dca43e1376 100644 --- a/editor/src/messages/portfolio/document/utility_types/transformation.rs +++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs @@ -83,21 +83,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 0da04b61af..d29f7353e8 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; @@ -1382,27 +1394,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; @@ -1492,7 +1483,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() { @@ -1507,7 +1498,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 a392c69f1dbea101f579ace573986a445b8b7efa Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 00:28:17 +0530 Subject: [PATCH 7/9] 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 dca43e1376..63e1b8baa8 100644 --- a/editor/src/messages/portfolio/document/utility_types/transformation.rs +++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs @@ -83,10 +83,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 12daf272a4f82b918191592eea88b8daaa9126f8 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 09:08:02 +0530 Subject: [PATCH 8/9] 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 0c34a438a4..3a2265588b 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -375,33 +375,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 8ea280c4ac2903bce824668b7f1c48f4f4ab00ce Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Tue, 3 Feb 2026 09:33:49 +0530 Subject: [PATCH 9/9] 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 d29f7353e8..d7eda7f305 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) { @@ -1395,7 +1395,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) @@ -1483,7 +1483,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() { @@ -1508,7 +1508,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) => {