Skip to content
Open
14 changes: 3 additions & 11 deletions editor/src/messages/portfolio/document/document_message_handler.rs
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just a minor refactor to use the shared utility function here

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::messages::portfolio::document::overlays::grid_overlays::{grid_overlay
use crate::messages::portfolio::document::overlays::utility_types::{OverlaysType, OverlaysVisibilitySettings, Pivot};
use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ};
use crate::messages::portfolio::document::utility_types::misc::{AlignAxis, FlipAxis, PTZ};
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate};
use crate::messages::portfolio::document::utility_types::nodes::RawBuffer;
use crate::messages::portfolio::utility_types::{FontCatalog, PanelType, PersistentData};
Expand Down Expand Up @@ -282,22 +282,14 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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);
Expand Down
11 changes: 11 additions & 0 deletions editor/src/messages/portfolio/document/utility_types/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure what to name so just named it as target_position ( min , max or center ) , open to other suggestions as well

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]
Expand Down
164 changes: 163 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 @@ -136,6 +136,18 @@ impl SelectedLayerState {
}
}

/// Returns selected points plus the anchor endpoints of any selected segments.
pub fn selected_and_segment_anchor_points(&self, vector: &Vector) -> HashSet<ManipulatorPointId> {
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;
Expand Down Expand Up @@ -1450,6 +1462,156 @@ impl ShapeState {
Some([(handles[0], start), (handles[1], end)])
}

/// Calculate the delta for a point in document space
fn calculate_alignment_delta_in_document_space(viewport_pos: DVec2, aggregated: DVec2, axis_vec: DVec2, transform_to_viewport: DAffine2) -> DVec2 {
let translation_viewport = (aggregated - viewport_pos) * axis_vec;
let transform_to_document = transform_to_viewport.inverse();
transform_to_document.transform_vector2(translation_viewport)
}

/// Process handle alignment and generate the modification message
fn process_handle_alignment(
layer: LayerNodeIdentifier,
point: ManipulatorPointId,
original_viewport_pos: DVec2,
aggregated: DVec2,
axis_vec: DVec2,
anchor_deltas: &std::collections::HashMap<(LayerNodeIdentifier, PointId), DVec2>,
vector: &Vector,
transform_to_viewport: DAffine2,
responses: &mut VecDeque<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 = state.selected_and_segment_anchor_points(&vector);

// Collect positions
for &point in affected_points.iter() {
if let Some(position) = point.get_position(&vector) {
let viewport_pos = transform_to_viewport.transform_point2(position);
point_positions.push((layer, point, viewport_pos));
}
}
}

// Calculate bounding box and alignment target
let Some(combined_box) = graphene_std::renderer::Rect::point_iter(point_positions.iter().map(|(_, _, pos)| *pos)) else {
return;
};
let aggregated = aggregate.target_position(combined_box.0);

// Separate anchor and handle movements
// We apply anchor movements first, then handle movements matches the behavior of Scale (S shortcut) when scaling to 0
let mut anchor_movements = Vec::new();
let mut anchor_deltas = std::collections::HashMap::new();
let mut handle_movements = Vec::new();

for (layer, point, viewport_pos) in point_positions {
let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
let delta = Self::calculate_alignment_delta_in_document_space(viewport_pos, aggregated, axis_vec, transform_to_viewport);

match point {
ManipulatorPointId::Anchor(point_id) => {
anchor_movements.push((layer, VectorModificationType::ApplyPointDelta { point: point_id, delta }));
anchor_deltas.insert((layer, point_id), delta);
}
ManipulatorPointId::PrimaryHandle(_) | ManipulatorPointId::EndHandle(_) => {
handle_movements.push((layer, point, viewport_pos));
}
}
}

// Apply anchor movements first
for (layer, modification_type) in anchor_movements {
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}

// TODO: figure out this Special case: When exactly 2 anchors are selected, skip handle transformations
let selected_anchor_count = anchor_deltas.len();
if selected_anchor_count == 2 {
return;
}

// Process handle movements
// We need to manually calculate the anchor's NEW position (original + delta)
// because compute_modified_vector() hasn't applied the anchor movements yet
// This matches the behavior of Scale (S) when scaling to 0
for (layer, point, original_viewport_pos) in handle_movements {
let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue };
let transform_to_viewport = document.network_interface.document_metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);

Self::process_handle_alignment(layer, point, original_viewport_pos, aggregated, axis_vec, &anchor_deltas, &vector, transform_to_viewport, responses);
}
}

/// Dissolve the selected points.
pub fn delete_selected_points(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>, start_transaction: bool) {
let mut transaction_started = false;
Expand Down
91 changes: 65 additions & 26 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 @@ -152,6 +153,10 @@ pub enum PathToolMessage {
Duplicate,
TogglePointEditing,
ToggleSegmentEditing,
AlignSelectedManipulatorPoints {
axis: AlignAxis,
aggregate: AlignAggregate,
},
}

#[derive(PartialEq, Eq, Hash, Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)]
Expand Down Expand Up @@ -187,6 +192,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 @@ -348,32 +376,35 @@ impl LayoutHolder for PathTool {

let _pin_pivot = pin_pivot_widget(self.tool_data.pivot_gizmo.pin_active(), false, PivotToolSource::Path);

Layout(vec![LayoutGroup::Row {
widgets: vec![
x_location,
related_seperator.clone(),
y_location,
unrelated_seperator.clone(),
colinear_handle_checkbox,
related_seperator.clone(),
colinear_handles_label,
unrelated_seperator.clone(),
point_editing_mode,
related_seperator.clone(),
segment_editing_mode,
unrelated_seperator.clone(),
path_overlay_mode_widget,
unrelated_seperator.clone(),
path_node_button,
// checkbox.clone(),
// related_seperator.clone(),
// dropdown.clone(),
// unrelated_seperator,
// pivot_reference,
// related_seperator.clone(),
// pin_pivot,
],
}])
// Determine if alignment buttons should be disabled (need 2+ points selected)
let multiple_points_selected = matches!(self.tool_data.selection_status, SelectionStatus::Multiple(_));
let alignment_disabled = !multiple_points_selected;

let mut widgets = vec![x_location, related_seperator.clone(), y_location, unrelated_seperator.clone()];
widgets.extend(self.alignment_widgets(alignment_disabled));
widgets.extend(vec![
unrelated_seperator.clone(),
colinear_handle_checkbox,
related_seperator.clone(),
colinear_handles_label,
unrelated_seperator.clone(),
point_editing_mode,
related_seperator.clone(),
segment_editing_mode,
unrelated_seperator.clone(),
path_overlay_mode_widget,
unrelated_seperator.clone(),
path_node_button,
// checkbox.clone(),
// related_seperator.clone(),
// dropdown.clone(),
// unrelated_seperator,
// pivot_reference,
// related_seperator.clone(),
// pin_pivot,
]);

Layout(vec![LayoutGroup::Row { widgets }])
}
}

Expand Down Expand Up @@ -3025,6 +3056,14 @@ impl Fsm for PathToolFsmState {

PathToolFsmState::Ready
}
(_, PathToolMessage::AlignSelectedManipulatorPoints { axis, aggregate }) => {
responses.add(DocumentMessage::AddTransaction);
shape_editor.align_selected_points(document, responses, axis, aggregate);
responses.add(DocumentMessage::EndTransaction);
responses.add(OverlaysMessage::Draw);

PathToolFsmState::Ready
}
(_, PathToolMessage::DoubleClick { extend_selection, shrink_selection }) => {
// Double-clicked on a point (flip smooth/sharp behavior)
let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);
Expand Down
Loading