Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 184 additions & 1 deletion editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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<ManipulatorPointId> {
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<Message>,
) {
// 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<Message>, 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a TODO here based on this discussion

#3375 (comment)

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<Message>, start_transaction: bool) {
let mut transaction_started = false;
Expand Down
89 changes: 65 additions & 24 deletions editor/src/messages/tool/tool_messages/path_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -182,6 +187,29 @@ pub enum PathOptionsUpdate {
TogglePivotPinned,
}

impl PathTool {
fn alignment_widgets(&self, disabled: bool) -> impl Iterator<Item = WidgetInstance> + 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()
Expand Down Expand Up @@ -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(),
}])
}
}
Expand Down Expand Up @@ -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);
Expand Down
Loading