diff --git a/editor/src/consts.rs b/editor/src/consts.rs index b4b3d23feb..240e42bc8e 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -122,6 +122,7 @@ pub const ASYMPTOTIC_EFFECT: f64 = 0.5; pub const SCALE_EFFECT: f64 = 0.5; // COLORS +pub const COLOR_OVERLAY_TRANSPARENT: &str = "transparent"; pub const COLOR_OVERLAY_BLUE: &str = "#00a8ff"; pub const COLOR_OVERLAY_YELLOW: &str = "#ffc848"; pub const COLOR_OVERLAY_GREEN: &str = "#63ce63"; diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 76d2fc7bc6..38ab57a846 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -251,6 +251,8 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowLeft], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: -BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }), entry!(KeyDown(ArrowDown); modifiers=[Shift, ArrowRight], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: BIG_NUDGE_AMOUNT }), entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=ToolMessage::Path(PathToolMessage::ClosePath)), + entry!(KeyDown(KeyP); modifiers=[Alt], action_dispatch=PathToolMessage::ToggleProportionalEditing), + entry!(WheelScroll; action_dispatch=PathToolMessage::AdjustProportionalRadius), // // PenToolMessage entry!(PointerMove; refresh_keys=[Control, Alt, Shift, KeyC], action_dispatch=PenToolMessage::PointerMove { snap_angle: Shift, break_handle: Alt, lock_angle: Control, colinear: KeyC, move_anchor_with_handles: Space }), @@ -376,9 +378,9 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(ArrowRight); action_dispatch=DocumentMessage::NudgeSelectedLayers { delta_x: NUDGE_AMOUNT, delta_y: 0., resize: Alt, resize_opposite_corner: Control }), // // TransformLayerMessage - entry!(KeyDown(KeyG); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Grab }), - entry!(KeyDown(KeyR); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Rotate }), - entry!(KeyDown(KeyS); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Scale }), + entry!(KeyDown(KeyG); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Grab, proportional_editing_data: None }), + entry!(KeyDown(KeyR); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Rotate, proportional_editing_data: None }), + entry!(KeyDown(KeyS); action_dispatch=TransformLayerMessage::BeginGRS { transform_type: TransformType::Scale, proportional_editing_data: None }), entry!(KeyDown(Digit0); action_dispatch=TransformLayerMessage::TypeDigit { digit: 0 }), entry!(KeyDown(Digit1); action_dispatch=TransformLayerMessage::TypeDigit { digit: 1 }), entry!(KeyDown(Digit2); action_dispatch=TransformLayerMessage::TypeDigit { digit: 2 }), diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 33353dbfb0..8b2ba344a1 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -67,6 +67,10 @@ pub struct DropdownInput { #[serde(skip)] pub tooltip_shortcut: Option, + + // Styling + #[serde(rename = "minWidth")] + pub min_width: u32, // // Callbacks // `on_update` exists on the `MenuListEntry`, not this parent `DropdownInput` diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index e9ad9ae117..34fed91e42 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -4,4 +4,5 @@ pub mod error; pub mod misc; pub mod network_interface; pub mod nodes; +pub mod proportional_editing; pub mod transformation; diff --git a/editor/src/messages/portfolio/document/utility_types/proportional_editing.rs b/editor/src/messages/portfolio/document/utility_types/proportional_editing.rs new file mode 100644 index 0000000000..470d892d7f --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/proportional_editing.rs @@ -0,0 +1,273 @@ +use glam::DVec2; +use graphene_std::vector::PointId; +use std::collections::{HashMap, HashSet}; + +use super::document_metadata::LayerNodeIdentifier; +use crate::messages::prelude::*; +use crate::messages::tool::{ + common_functionality::shape_editor::ShapeState, + tool_messages::{ + path_tool::{PathOptionsUpdate, PathToolData, PathToolOptions}, + tool_prelude::{DropdownInput, LayoutGroup, MenuListEntry, NumberInput, Separator, SeparatorType, TextLabel}, + }, +}; + +#[derive(PartialEq, Eq, Hash, Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum ProportionalFalloffType { + #[default] + Smooth = 0, + Sphere = 1, + Root = 2, + InverseSquare = 3, + Sharp = 4, + Linear = 5, + Constant = 6, + Random = 7, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct ProportionalEditingData { + pub center: DVec2, + pub affected_points: HashMap>, + pub falloff_type: ProportionalFalloffType, + pub radius: u32, +} + +pub fn proportional_editing_options(options: &PathToolOptions) -> Vec { + let mut widgets = Vec::new(); + + // Header row with title + widgets.push(LayoutGroup::Row { + widgets: vec![TextLabel::new("Proportional Editing").bold(true).widget_holder()], + }); + + let callback = |message| Message::Batched(Box::new([PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalEditingEnabled(true)).into(), message])); + + // Falloff type row + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Falloff").table_align(true).min_width(80).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + DropdownInput::new(vec![vec![ + MenuListEntry::new("Smooth") + .label("Smooth") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Smooth)).into())), + MenuListEntry::new("Sphere") + .label("Sphere") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Sphere)).into())), + MenuListEntry::new("Root") + .label("Root") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Root)).into())), + MenuListEntry::new("Inverse Square") + .label("Inverse Square") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::InverseSquare)).into())), + MenuListEntry::new("Sharp") + .label("Sharp") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Sharp)).into())), + MenuListEntry::new("Linear") + .label("Linear") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Linear)).into())), + MenuListEntry::new("Constant") + .label("Constant") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Constant)).into())), + MenuListEntry::new("Random") + .label("Random") + .on_commit(move |_| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalFalloffType(ProportionalFalloffType::Random)).into())), + ]]) + .min_width(120) + .selected_index(Some(options.proportional_falloff_type as u32)) + .widget_holder(), + ], + }); + + // Radius row + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Radius").table_align(true).min_width(80).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + NumberInput::new(Some(options.proportional_radius as f64)) + .unit(" px") + .min(1.) + .int() + .min_width(120) + .on_update(move |number_input| callback(PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalRadius(number_input.value.unwrap_or(1.) as u32)).into())) + .widget_holder(), + ], + }); + + widgets +} + +pub fn calculate_proportional_affected_points( + path_tool_data: &mut PathToolData, + document: &DocumentMessageHandler, + shape_editor: &ShapeState, + radius: u32, + proportional_falloff_type: ProportionalFalloffType, +) { + path_tool_data.proportional_affected_points.clear(); + + let radius = radius as f64; + + // If initial positions haven't been stored yet, do it now + if path_tool_data.initial_point_positions.is_empty() { + store_initial_point_positions(path_tool_data, document); + } + + // Collect all selected points with their initial world positions + let mut selected_points_world_pos = Vec::new(); + let selected_point_ids: HashSet<_> = shape_editor.selected_points().filter_map(|point| point.as_anchor()).collect(); + + // Extract initial positions of selected points + for (_layer, points_map) in &path_tool_data.initial_point_positions { + for &point_id in &selected_point_ids { + if let Some(&world_pos) = points_map.get(&point_id) { + selected_points_world_pos.push(world_pos); + } + } + } + + // Find all affected points using initial positions + for (layer, points_map) in &path_tool_data.initial_point_positions { + let selected_points: HashSet<_> = shape_editor.selected_points().filter_map(|point| point.as_anchor()).collect(); + + let mut layer_affected_points = Vec::new(); + + // Check each point in the layer + for (&point_id, &initial_position) in points_map { + if !selected_points.contains(&point_id) { + // Find the smallest distance to any selected point using initial positions + let min_distance = selected_points_world_pos + .iter() + .map(|&selected_pos| initial_position.distance(selected_pos)) + .filter(|&distance| distance <= radius) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some(distance) = min_distance { + let factor = path_tool_data.calculate_falloff_factor(distance, radius, proportional_falloff_type); + layer_affected_points.push((point_id, factor)); + } + } + } + + if !layer_affected_points.is_empty() { + path_tool_data.proportional_affected_points.insert(*layer, layer_affected_points); + } + } + + // Find all affected points using initial positions + // NOTE: This works based on initial affected point location -> original selected point location for falloff calculation + for (layer, points_map) in &path_tool_data.initial_point_positions { + let selected_points: HashSet<_> = shape_editor.selected_points().filter_map(|point| point.as_anchor()).collect(); + + let mut layer_affected_points = Vec::new(); + + // Check each point in the layer + for (&point_id, &initial_position) in points_map { + if !selected_points.contains(&point_id) { + // Find the smallest distance to any selected point using initial positions + let min_distance = selected_points_world_pos + .iter() + .map(|&selected_pos| initial_position.distance(selected_pos)) + .filter(|&distance| distance <= radius) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some(distance) = min_distance { + let factor = path_tool_data.calculate_falloff_factor(distance, radius, proportional_falloff_type); + layer_affected_points.push((point_id, factor)); + } + } + } + + if !layer_affected_points.is_empty() { + path_tool_data.proportional_affected_points.insert(*layer, layer_affected_points); + } + } +} + +pub fn store_initial_point_positions(path_tool_data: &mut PathToolData, document: &DocumentMessageHandler) { + path_tool_data.initial_point_positions.clear(); + + // Store positions of all points in selected layers + for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) { + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + let transform = document.metadata().transform_to_document(layer); + let mut layer_points = HashMap::new(); + + // Store all point positions in document space + for (i, &point_id) in vector_data.point_domain.ids().iter().enumerate() { + let position = vector_data.point_domain.positions()[i]; + let world_pos = transform.transform_point2(position); + layer_points.insert(point_id, world_pos); + } + + if !layer_points.is_empty() { + path_tool_data.initial_point_positions.insert(layer, layer_points); + } + } + } +} + +pub fn update_proportional_positions(path_tool_data: &mut PathToolData, document: &DocumentMessageHandler, shape_editor: &mut ShapeState, responses: &mut VecDeque) { + // Get a set of all selected point IDs across all layers + let selected_points: HashSet = shape_editor.selected_points().filter_map(|point| point.as_anchor()).collect(); + + for (layer, affected_points) in &path_tool_data.proportional_affected_points { + if let Some(vector_data) = document.network_interface.compute_modified_vector(*layer) { + let transform = document.metadata().transform_to_document(*layer); + let inverse_transform = transform.inverse(); + + for (point_id, factor) in affected_points { + // Skip this point if it's in the selected_points set + if selected_points.contains(point_id) { + continue; + } + + if let Some(initial_doc_pos) = path_tool_data.initial_point_positions.get(layer).and_then(|pts| pts.get(point_id)) { + // Calculate displacement from initial position to target position + let displacement_document_space = path_tool_data.total_delta * (*factor); + let target_document_space_position = *initial_doc_pos + displacement_document_space; + let target_layer_space_position = inverse_transform.transform_point2(target_document_space_position); + + // Get current position and calculate delta + if let Some(current_layer_space_position) = vector_data.point_domain.position_from_id(*point_id) { + let delta = target_layer_space_position - current_layer_space_position; + shape_editor.move_anchor(*point_id, &vector_data, delta, *layer, None, responses); + } + } + } + } + } +} + +pub fn reset_removed_points( + path_tool_data: &mut PathToolData, + previous: &HashMap>, + document: &DocumentMessageHandler, + shape_editor: &mut ShapeState, + responses: &mut VecDeque, +) { + for (layer, prev_points) in previous { + let current_points = path_tool_data + .proportional_affected_points + .get(layer) + .map(|v| v.iter().map(|(id, _)| *id).collect::>()) + .unwrap_or_default(); + + for (point_id, _) in prev_points { + if !current_points.contains(point_id) { + if let Some(initial_doc_pos) = path_tool_data.initial_point_positions.get(layer).and_then(|pts| pts.get(point_id)) { + let inverse_transform = document.metadata().transform_to_document(*layer).inverse(); + let target_layer_pos = inverse_transform.transform_point2(*initial_doc_pos); + + if let Some(vector_data) = document.network_interface.compute_modified_vector(*layer) { + if let Some(current_layer_pos) = vector_data.point_domain.position_from_id(*point_id) { + let delta = target_layer_pos - current_layer_pos; + shape_editor.move_anchor(*point_id, &vector_data, delta, *layer, None, responses); + } + } + } + } + } + } +} diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 7fa761acdc..6b30cfa530 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -253,6 +253,34 @@ impl ClosestSegment { // TODO Consider keeping a list of selected manipulators to minimize traversals of the layers impl ShapeState { + /// Calculates the center point of all selected manipulator points (anchors and handles) + pub fn selection_center(&self, document: &DocumentMessageHandler) -> Option { + let mut sum = DVec2::ZERO; + let mut count = 0; + + // Iterate through all selected layers and their selection states + for (&layer, state) in &self.selected_shape_state { + // Get the transform from layer space to document space + let transform = document.metadata().transform_to_document(layer); + + // Get the vector data for this layer + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + // Process each selected point in this layer + for point in state.selected() { + // Get the position in layer space coordinates + if let Some(position) = point.get_position(&vector_data) { + // Convert to document space and accumulate + sum += transform.transform_point2(position); + count += 1; + } + } + } + } + + // Return average position if we have any points + if count > 0 { Some(sum / count as f64) } else { None } + } + pub fn close_selected_path(&self, document: &DocumentMessageHandler, responses: &mut VecDeque) { // First collect all selected anchor points across all layers let all_selected_points: Vec<(LayerNodeIdentifier, PointId)> = self diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 41e5f1f1c7..7a432d593c 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -1,14 +1,16 @@ use super::select_tool::extend_lasso; use super::tool_prelude::*; use crate::consts::{ - COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, SEGMENT_INSERTION_DISTANCE, - SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, SELECTION_TOLERANCE, + COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_TRANSPARENT, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, + SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, SELECTION_TOLERANCE, }; +use crate::messages::input_mapper::utility_types::macros::action_keys; use crate::messages::portfolio::document::overlays::utility_functions::{path_overlays, selected_segments}; use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, OverlayContext}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; -use crate::messages::portfolio::document::utility_types::transformation::Axis; +use crate::messages::portfolio::document::utility_types::proportional_editing::*; +use crate::messages::portfolio::document::utility_types::transformation::{Axis, TransformType}; use crate::messages::preferences::SelectionMode; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::shape_editor::{ @@ -18,6 +20,7 @@ use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandi use crate::messages::tool::common_functionality::utility_functions::calculate_segment_angle; use graphene_core::renderer::Quad; use graphene_core::vector::{ManipulatorPointId, PointId, VectorModificationType}; +use graphene_core::{ChaCha20Rng, Rng, SeedableRng}; use graphene_std::vector::{HandleId, NoHashBuilder, SegmentId, VectorData}; use std::vec; @@ -28,9 +31,21 @@ pub struct PathTool { options: PathToolOptions, } -#[derive(Default)] pub struct PathToolOptions { path_overlay_mode: PathOverlayMode, + pub proportional_editing_enabled: bool, + pub proportional_falloff_type: ProportionalFalloffType, + pub proportional_radius: u32, +} +impl Default for PathToolOptions { + fn default() -> Self { + Self { + path_overlay_mode: PathOverlayMode::default(), + proportional_editing_enabled: false, + proportional_falloff_type: ProportionalFalloffType::default(), + proportional_radius: 100, + } + } } #[impl_message(Message, ToolMessage, Path)] @@ -99,6 +114,8 @@ pub enum PathToolMessage { }, SwapSelectedHandles, UpdateOptions(PathOptionsUpdate), + ToggleProportionalEditing, + AdjustProportionalRadius, } #[derive(PartialEq, Eq, Hash, Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)] @@ -112,6 +129,9 @@ pub enum PathOverlayMode { #[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize, specta::Type)] pub enum PathOptionsUpdate { OverlayModeType(PathOverlayMode), + ProportionalEditingEnabled(bool), + ProportionalFalloffType(ProportionalFalloffType), + ProportionalRadius(u32), } impl ToolMetadata for PathTool { @@ -210,6 +230,15 @@ impl LayoutHolder for PathTool { .selected_index(Some(self.options.path_overlay_mode as u32)) .widget_holder(); + let proportional_editing_trigger = CheckboxInput::new(self.options.proportional_editing_enabled) + // TODO(Keavon): Replace placeholder icon with a proper one + .icon("Empty12px") + .tooltip("Proportional Editing") + .tooltip_shortcut(action_keys!(PathToolMessageDiscriminant::ToggleProportionalEditing)) + .on_update(|checkbox| PathToolMessage::UpdateOptions(PathOptionsUpdate::ProportionalEditingEnabled(checkbox.checked)).into()) + .widget_holder(); + let proportional_editing_dropdown = PopoverButton::new().popover_layout(proportional_editing_options(&self.options)).widget_holder(); + Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets: vec![ x_location, @@ -219,8 +248,11 @@ impl LayoutHolder for PathTool { colinear_handle_checkbox, related_seperator, colinear_handles_label, - unrelated_seperator, + unrelated_seperator.clone(), path_overlay_mode_widget, + unrelated_seperator, + proportional_editing_trigger, + proportional_editing_dropdown, ], }])) } @@ -236,7 +268,86 @@ impl<'a> MessageHandler> for PathToo self.options.path_overlay_mode = overlay_mode_type; responses.add(OverlaysMessage::Draw); } + PathOptionsUpdate::ProportionalEditingEnabled(enabled) => { + self.options.proportional_editing_enabled = enabled; + + responses.add(OverlaysMessage::Draw); + } + PathOptionsUpdate::ProportionalFalloffType(falloff_type) => { + self.options.proportional_falloff_type = falloff_type.clone(); + self.tool_data + .calculate_proportional_affected_points(&tool_data.document, &tool_data.shape_editor, self.options.proportional_radius, self.options.proportional_falloff_type); + + if self.options.proportional_editing_enabled { + let proportional_data = ProportionalEditingData { + center: self.tool_data.proportional_editing_center.unwrap_or_default(), + affected_points: self.tool_data.proportional_affected_points.clone(), + falloff_type: self.options.proportional_falloff_type, + radius: self.options.proportional_radius, + }; + responses.add(TransformLayerMessage::UpdateProportionalEditingData { data: proportional_data }); + } + responses.add(OverlaysMessage::Draw); + } + PathOptionsUpdate::ProportionalRadius(radius) => { + self.options.proportional_radius = radius.clamp(1, 1000); + self.tool_data + .calculate_proportional_affected_points(&tool_data.document, &tool_data.shape_editor, self.options.proportional_radius, self.options.proportional_falloff_type); + responses.add(OverlaysMessage::Draw); + } }, + ToolMessage::Path(PathToolMessage::ToggleProportionalEditing) => { + self.options.proportional_editing_enabled ^= true; + + responses.add(OverlaysMessage::Draw); + } + ToolMessage::Path(PathToolMessage::AdjustProportionalRadius) => { + if self.options.proportional_editing_enabled { + // Get the current radius and scroll delta + let current_radius = self.options.proportional_radius as f64; + let scroll_delta = (tool_data.input.mouse.scroll_delta.y as f64).min(1.).max(-1.); + + // Base factor that determines how aggressive the scaling is + let base_factor = 0.15; // Sensitivity + + // Calculate new radius using logarithmic scaling + let scale_factor = if scroll_delta > 0. { + 1. + (base_factor * scroll_delta) + } else { + 1. / (1. + (base_factor * -scroll_delta)) + }; + + let new_radius = (current_radius * scale_factor).round() as u32; + + // Ensure the radius stays within reasonable bounds + self.options.proportional_radius = new_radius.max(1); + + // Store previous affected points + let previous_affected = self.tool_data.proportional_affected_points.clone(); + + self.tool_data + .calculate_proportional_affected_points(&tool_data.document, &tool_data.shape_editor, self.options.proportional_radius, self.options.proportional_falloff_type); + if self.tool_data.is_dragging { + // Reset points no longer affected + reset_removed_points(&mut self.tool_data, &previous_affected, tool_data.document, tool_data.shape_editor, responses); + + // Update current points + update_proportional_positions(&mut self.tool_data, tool_data.document, tool_data.shape_editor, responses); + } + // Create updated proportional editing data + let proportional_data = ProportionalEditingData { + center: self.tool_data.proportional_editing_center.unwrap_or_default(), + affected_points: self.tool_data.proportional_affected_points.clone(), + falloff_type: self.options.proportional_falloff_type, + radius: self.options.proportional_radius, + }; + + // Send the updated data to any active GRS operation + responses.add(TransformLayerMessage::UpdateProportionalEditingData { data: proportional_data }); + + responses.add(OverlaysMessage::Draw); + } + } ToolMessage::Path(PathToolMessage::ClosePath) => { responses.add(DocumentMessage::AddTransaction); tool_data.shape_editor.close_selected_path(tool_data.document, responses); @@ -275,7 +386,10 @@ impl<'a> MessageHandler> for PathToo BreakPath, DeleteAndBreakPath, ClosePath, - PointerMove, + ToggleProportionalEditing, + AdjustProportionalRadius, + GRS, + PointerMove ), PathToolFsmState::Dragging(_) => actions!(PathToolMessageDiscriminant; Escape, @@ -287,6 +401,8 @@ impl<'a> MessageHandler> for PathToo BreakPath, DeleteAndBreakPath, SwapSelectedHandles, + AdjustProportionalRadius, + GRS ), PathToolFsmState::Drawing { .. } => actions!(PathToolMessageDiscriminant; FlipSmoothSharp, @@ -298,6 +414,7 @@ impl<'a> MessageHandler> for PathToo DeleteAndBreakPath, Escape, RightClick, + AdjustProportionalRadius, ), } } @@ -313,6 +430,7 @@ impl ToolTransition for PathTool { } } } + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct DraggingState { point_select_state: PointSelectState, @@ -338,7 +456,7 @@ enum PathToolFsmState { } #[derive(Default)] -struct PathToolData { +pub struct PathToolData { snap_manager: SnapManager, lasso_polygon: Vec, selection_mode: Option, @@ -367,6 +485,13 @@ struct PathToolData { opposite_handle_position: Option, last_clicked_point_was_selected: bool, snapping_axis: Option, + + pub proportional_editing_center: Option, + pub proportional_affected_points: HashMap>, + pub initial_point_positions: HashMap>, + pub total_delta: DVec2, + is_dragging: bool, + alt_clicked_on_anchor: bool, alt_dragging_from_anchor: bool, angle_locked: bool, @@ -438,6 +563,7 @@ impl PathToolData { extend_selection: bool, lasso_select: bool, handle_drag_from_anchor: bool, + tool_options: &PathToolOptions, ) -> PathToolFsmState { self.double_click_handled = false; self.opposing_handle_lengths = None; @@ -500,7 +626,8 @@ impl PathToolData { } } - self.start_dragging_point(selected_points, input, document, shape_editor); + self.start_dragging_point(selected_points, input, document, shape_editor, tool_options); + responses.add(OverlaysMessage::Draw); } PathToolFsmState::Dragging(self.dragging_state) @@ -548,9 +675,24 @@ impl PathToolData { } } - fn start_dragging_point(&mut self, selected_points: SelectedPointsInfo, input: &InputPreprocessorMessageHandler, document: &DocumentMessageHandler, shape_editor: &mut ShapeState) { + fn calculate_proportional_affected_points(&mut self, document: &DocumentMessageHandler, shape_editor: &ShapeState, radius: u32, proportional_falloff_type: ProportionalFalloffType) { + calculate_proportional_affected_points(self, document, shape_editor, radius, proportional_falloff_type); + } + + fn start_dragging_point( + &mut self, + selected_points: SelectedPointsInfo, + input: &InputPreprocessorMessageHandler, + document: &DocumentMessageHandler, + shape_editor: &mut ShapeState, + tool_options: &PathToolOptions, + ) { let mut manipulators = HashMap::with_hasher(NoHashBuilder); let mut unselected = Vec::new(); + self.initial_point_positions.clear(); + self.proportional_editing_center = shape_editor.selection_center(document); + self.calculate_proportional_affected_points(document, shape_editor, tool_options.proportional_radius, tool_options.proportional_falloff_type); + self.total_delta = DVec2::default(); for (&layer, state) in &shape_editor.selected_shape_state { let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue; @@ -646,8 +788,10 @@ impl PathToolData { } } } + self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document)); } + false } @@ -828,7 +972,10 @@ impl PathToolData { document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, + tool_options: &PathToolOptions, ) { + self.is_dragging = true; + // First check if selection is not just a single handle point let selected_points = shape_editor.selected_points(); let single_handle_selected = selected_points.count() == 1 @@ -868,6 +1015,8 @@ impl PathToolData { let mut was_alt_dragging = false; if self.snapping_axis.is_none() { + self.total_delta += snapped_delta; + if self.alt_clicked_on_anchor && !self.alt_dragging_from_anchor && self.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD { // Checking which direction the dragging begins self.alt_dragging_from_anchor = true; @@ -917,6 +1066,7 @@ impl PathToolData { skip_opposite = true; } shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, true, was_alt_dragging, opposite, skip_opposite, responses); + self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta); } else { let Some(axis) = self.snapping_axis else { return }; @@ -925,10 +1075,18 @@ impl PathToolData { Axis::Y => DVec2::new(0., unsnapped_delta.y), _ => DVec2::new(unsnapped_delta.x, 0.), }; + + self.total_delta += unsnapped_delta; + shape_editor.move_selected_points(handle_lengths, document, projected_delta, equidistant, true, false, opposite, false, responses); + self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(unsnapped_delta); } + if tool_options.proportional_editing_enabled && !self.proportional_affected_points.is_empty() { + update_proportional_positions(self, document, shape_editor, responses); + } + if snap_angle && self.snapping_axis.is_some() { let Some(current_axis) = self.snapping_axis else { return }; let total_delta = self.drag_start_pos - input.mouse.position; @@ -939,6 +1097,34 @@ impl PathToolData { } } } + + pub fn calculate_falloff_factor(&self, distance: f64, radius: f64, falloff_type: ProportionalFalloffType) -> f64 { + // Handle edge cases + if distance >= radius { + return 0.; + } + if distance <= 0.001 { + return 1.; + } + + let normalized_distance = distance / radius; + + match falloff_type { + ProportionalFalloffType::Constant => 1., + ProportionalFalloffType::Linear => 1. - normalized_distance, + ProportionalFalloffType::Sharp => (1. - normalized_distance).powi(2), + ProportionalFalloffType::Root => (1. - normalized_distance).sqrt(), + ProportionalFalloffType::Sphere => (1. - normalized_distance.powi(2)).sqrt(), + ProportionalFalloffType::Smooth => 1. - (normalized_distance.powi(2) * (3. - 2. * normalized_distance)), + ProportionalFalloffType::Random => { + // Seed RNG with position-based value for consistency + let seed = (distance * 1000.) as u64; + let mut rng = ChaCha20Rng::seed_from_u64(seed); + rng.random_range(0. ..1.) * (1. - normalized_distance) + } + ProportionalFalloffType::InverseSquare => 1. / (normalized_distance.powi(2) * 2. + 1.), + } + } } impl Fsm for PathToolFsmState { @@ -1072,7 +1258,14 @@ impl Fsm for PathToolFsmState { } Self::Dragging(_) => { tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context); + if tool_options.proportional_editing_enabled && tool_data.is_dragging { + if let Some(center) = tool_data.proportional_editing_center { + let viewport_center = document.metadata().document_to_viewport.transform_point2(center); + let radius_viewport = document.metadata().document_to_viewport.transform_vector2(DVec2::X * tool_options.proportional_radius as f64).x; + overlay_context.circle(viewport_center, radius_viewport, Some(COLOR_OVERLAY_TRANSPARENT), Some(COLOR_OVERLAY_BLUE)); + } + } // Draw the snapping axis lines if tool_data.snapping_axis.is_some() { let Some(axis) = tool_data.snapping_axis else { return self }; @@ -1099,6 +1292,38 @@ impl Fsm for PathToolFsmState { } responses.add(PathToolMessage::SelectedPointUpdated); + + self + } + (_, PathToolMessage::GRS { key }) => { + // Calculate proportional editing center and affected points + tool_data.initial_point_positions.clear(); + tool_data.proportional_editing_center = shape_editor.selection_center(document); + tool_data.calculate_proportional_affected_points(document, shape_editor, tool_options.proportional_radius, tool_options.proportional_falloff_type); + + // Create proportional data to pass to transform layer + let mut proportional_data = Some(ProportionalEditingData { + center: tool_data.proportional_editing_center.unwrap_or_default(), + affected_points: tool_data.proportional_affected_points.clone(), + falloff_type: tool_options.proportional_falloff_type, + radius: tool_options.proportional_radius, + }); + + if !tool_options.proportional_editing_enabled { + proportional_data = None; + } + + // Dispatch transform operation with proportional data + responses.add(TransformLayerMessage::BeginGRS { + transform_type: match key { + Key::KeyG => TransformType::Grab, + Key::KeyR => TransformType::Rotate, + Key::KeyS => TransformType::Scale, + _ => TransformType::Grab, + }, + proportional_editing_data: proportional_data, + }); + self } @@ -1119,7 +1344,7 @@ impl Fsm for PathToolFsmState { tool_data.selection_mode = None; tool_data.lasso_polygon.clear(); - tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, lasso_select, handle_drag_from_anchor) + tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, lasso_select, handle_drag_from_anchor, tool_options) } ( PathToolFsmState::Drawing { selection_shape }, @@ -1232,6 +1457,7 @@ impl Fsm for PathToolFsmState { tool_action_data.document, input, responses, + tool_options, ); } @@ -1368,6 +1594,8 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Ready } (PathToolFsmState::Dragging { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => { + tool_data.is_dragging = false; + tool_data.initial_point_positions.clear(); if tool_data.handle_drag_toggle && tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD { shape_editor.deselect_all_points(); shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_handle_drag); @@ -1380,6 +1608,8 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Ready } (PathToolFsmState::Drawing { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => { + tool_data.is_dragging = false; + tool_data.initial_point_positions.clear(); tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } @@ -1407,6 +1637,8 @@ impl Fsm for PathToolFsmState { SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), select_kind), } } + tool_data.is_dragging = false; + tool_data.initial_point_positions.clear(); responses.add(OverlaysMessage::Draw); responses.add(PathToolMessage::SelectedPointUpdated); @@ -1470,6 +1702,8 @@ impl Fsm for PathToolFsmState { tool_data.snapping_axis = None; } + tool_data.is_dragging = false; + responses.add(DocumentMessage::EndTransaction); responses.add(PathToolMessage::SelectedPointUpdated); tool_data.snap_manager.cleanup(responses); @@ -1504,6 +1738,7 @@ impl Fsm for PathToolFsmState { responses.add(DocumentMessage::StartTransaction); shape_editor.flip_smooth_sharp(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE, responses); responses.add(DocumentMessage::EndTransaction); + responses.add(PathToolMessage::SelectedPointUpdated); } @@ -1572,13 +1807,16 @@ impl Fsm for PathToolFsmState { responses.add(DocumentMessage::StartTransaction); shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document); responses.add(DocumentMessage::EndTransaction); + responses.add(PathToolMessage::SelectionChanged); + PathToolFsmState::Ready } (_, PathToolMessage::ManipulatorMakeHandlesFree) => { responses.add(DocumentMessage::StartTransaction); shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses); responses.add(DocumentMessage::EndTransaction); + PathToolFsmState::Ready } (_, _) => PathToolFsmState::Ready, @@ -1808,7 +2046,7 @@ fn calculate_lock_angle( let angle_2 = calculate_segment_angle(anchor, segment, vector_data, false); match (angle_1, angle_2) { - (Some(angle_1), Some(angle_2)) => Some((angle_1 + angle_2) / 2.0), + (Some(angle_1), Some(angle_2)) => Some((angle_1 + angle_2) / 2.), (Some(angle_1), None) => Some(angle_1), (None, Some(angle_2)) => Some(angle_2), (None, None) => None, diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message.rs b/editor/src/messages/tool/transform_layer/transform_layer_message.rs index dfc45c1e05..f5bc6a9a27 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message.rs @@ -1,7 +1,9 @@ use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::proportional_editing::ProportionalEditingData; use crate::messages::portfolio::document::utility_types::transformation::TransformType; use crate::messages::prelude::*; + use glam::DVec2; #[impl_message(Message, ToolMessage, TransformLayer)] @@ -11,21 +13,43 @@ pub enum TransformLayerMessage { Overlays(OverlayContext), // Messages - ApplyTransformOperation { final_transform: bool }, + ApplyTransformOperation { + final_transform: bool, + }, BeginGrab, BeginRotate, BeginScale, - BeginGRS { transform_type: TransformType }, - BeginGrabPen { last_point: DVec2, handle: DVec2 }, - BeginRotatePen { last_point: DVec2, handle: DVec2 }, - BeginScalePen { last_point: DVec2, handle: DVec2 }, + BeginGRS { + transform_type: TransformType, + proportional_editing_data: Option, + }, + BeginGrabPen { + last_point: DVec2, + handle: DVec2, + }, + BeginRotatePen { + last_point: DVec2, + handle: DVec2, + }, + BeginScalePen { + last_point: DVec2, + handle: DVec2, + }, CancelTransformOperation, ConstrainX, ConstrainY, - PointerMove { slow_key: Key, increments_key: Key }, + PointerMove { + slow_key: Key, + increments_key: Key, + }, SelectionChanged, TypeBackspace, TypeDecimalPoint, - TypeDigit { digit: u8 }, + TypeDigit { + digit: u8, + }, TypeNegate, + UpdateProportionalEditingData { + data: ProportionalEditingData, + }, } diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index 0e44efabca..ddf2bc7164 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -1,8 +1,10 @@ -use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, SLOWING_DIVISOR}; +use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_TRANSPARENT, SLOWING_DIVISOR}; use crate::messages::input_mapper::utility_types::input_mouse::{DocumentPosition, ViewportPosition}; use crate::messages::portfolio::document::overlays::utility_types::{OverlayProvider, Pivot}; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; use crate::messages::portfolio::document::utility_types::misc::PTZ; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::portfolio::document::utility_types::proportional_editing::*; use crate::messages::portfolio::document::utility_types::transformation::{Axis, OriginalTransforms, Selected, TransformOperation, TransformType, Typing}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::shape_editor::ShapeState; @@ -11,7 +13,7 @@ use crate::messages::tool::utility_types::{ToolData, ToolType}; use glam::{DAffine2, DVec2}; use graphene_core::renderer::Quad; use graphene_core::vector::ManipulatorPointId; -use graphene_std::vector::{VectorData, VectorModificationType}; +use graphene_std::vector::{PointId, VectorData, VectorModificationType}; use std::f64::consts::{PI, TAU}; const TRANSFORM_GRS_OVERLAY_PROVIDER: OverlayProvider = |context| TransformLayerMessage::Overlays(context).into(); @@ -49,6 +51,10 @@ pub struct TransformLayerMessageHandler { handle: DVec2, last_point: DVec2, grs_pen_handle: bool, + + // Path tool (proportional editing) + initial_positions: HashMap>, + proportional_editing_data: Option, } impl TransformLayerMessageHandler { @@ -59,6 +65,54 @@ impl TransformLayerMessageHandler { pub fn hints(&self, responses: &mut VecDeque) { self.transform_operation.hints(responses, self.local); } + + pub fn calculate_total_transformation_vp(&self, document_to_viewport: DAffine2) -> DAffine2 { + let pivot_vp = document_to_viewport.transform_point2(self.local_pivot); + let local_axis_transform_angle = (self.layer_bounding_box.0[1] - self.layer_bounding_box.0[0]).to_angle(); + + match self.transform_operation { + TransformOperation::Grabbing(translation) => { + let total_delta_doc = translation.to_dvec(self.initial_transform, self.increments); + let translate = DAffine2::from_translation(document_to_viewport.transform_vector2(total_delta_doc)); + if self.local { + let resolved_angle = if local_axis_transform_angle > 0. { + local_axis_transform_angle + } else { + local_axis_transform_angle - std::f64::consts::PI + }; + DAffine2::from_angle(resolved_angle) * translate * DAffine2::from_angle(-resolved_angle) + } else { + translate + } + } + TransformOperation::Rotating(rotation) => { + let total_angle = rotation.to_f64(self.increments); + let pivot_transform = DAffine2::from_translation(pivot_vp); + pivot_transform * DAffine2::from_angle(total_angle) * pivot_transform.inverse() + } + TransformOperation::Scaling(scale) => { + let total_scale_vec = scale.to_dvec(self.increments); + let pivot_transform = DAffine2::from_translation(pivot_vp); + if self.local { + pivot_transform + * DAffine2::from_angle(local_axis_transform_angle) + * DAffine2::from_scale(total_scale_vec) + * DAffine2::from_angle(-local_axis_transform_angle) + * pivot_transform.inverse() + } else { + pivot_transform * DAffine2::from_scale(total_scale_vec) * pivot_transform.inverse() + } + } + TransformOperation::None => DAffine2::IDENTITY, + } + } + + // Apply proportional editing with the given transformation + fn apply_proportional_editing(&mut self, total_transformation_vp: DAffine2, document: &DocumentMessageHandler, responses: &mut VecDeque) { + if let Some(prop_data) = &self.proportional_editing_data { + apply_proportionaling_edit(&self.initial_positions, prop_data, total_transformation_vp, &document.network_interface, document.metadata(), responses); + } + } } fn calculate_pivot(selected_points: &Vec<&ManipulatorPointId>, vector_data: &VectorData, viewspace: DAffine2, get_location: impl Fn(&ManipulatorPointId) -> Option) -> Option<(DVec2, DVec2)> { @@ -105,7 +159,73 @@ fn project_edge_to_quad(edge: DVec2, quad: &Quad, local: bool, axis_constraint: _ => edge, } } +fn apply_proportionaling_edit( + initial_positions: &HashMap>, + proportional_data: &ProportionalEditingData, + total_transformation_vp: DAffine2, + network_interface: &NodeNetworkInterface, + document_metadata: &DocumentMetadata, + responses: &mut VecDeque, +) { + // Iterate through layers that have initial positions + for (layer, layer_initial_positions) in initial_positions { + // Get current vector data for position comparison + let Some(current_vector_data) = network_interface.compute_modified_vector(*layer) else { + continue; + }; + + let viewspace = document_metadata.transform_to_viewport(*layer); + + // Create a lookup map for affected points + let affected_points_map: HashMap = proportional_data + .affected_points + .get(layer) + .map(|points| points.iter().map(|(id, factor)| (*id, *factor)).collect()) + .unwrap_or_default(); + + // Process all points that were stored in initial positions + for (point_id, initial_pos_local) in layer_initial_positions { + let Some(current_pos_local) = current_vector_data.point_domain.position_from_id(*point_id) else { + continue; + }; + + // Transform initial position to viewport space + let initial_pos_vp = viewspace.transform_point2(*initial_pos_local); + + // Affected point: + // Apply proportional transformation + if let Some(factor) = affected_points_map.get(point_id) { + let target_pos_fully_transformed_vp = total_transformation_vp.transform_point2(initial_pos_vp); + let full_intended_delta_vp = target_pos_fully_transformed_vp - initial_pos_vp; + + let scaled_intended_delta_vp = full_intended_delta_vp * (*factor); + + let target_pos_proportional_vp = initial_pos_vp + scaled_intended_delta_vp; + let target_pos_proportional_local = viewspace.inverse().transform_point2(target_pos_proportional_vp); + let final_delta_local = target_pos_proportional_local - current_pos_local; + + if final_delta_local.length_squared() > 1e-10 { + let modification_type = VectorModificationType::ApplyPointDelta { + point: *point_id, + delta: final_delta_local, + }; + responses.add(GraphOperationMessage::Vector { layer: *layer, modification_type }); + } + } + // Non-affected point: + // Reset to original position + else { + let reset_delta = *initial_pos_local - current_pos_local; + + if reset_delta.length_squared() > 1e-10 { + let modification_type = VectorModificationType::ApplyPointDelta { point: *point_id, delta: reset_delta }; + responses.add(GraphOperationMessage::Vector { layer: *layer, modification_type }); + } + } + } + } +} fn update_colinear_handles(selected_layers: &[LayerNodeIdentifier], document: &DocumentMessageHandler, responses: &mut VecDeque) { for &layer in selected_layers { let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue }; @@ -212,6 +332,12 @@ impl MessageHandler> for TransformLayer if !overlay_context.visibility_settings.transform_measurement() { return; } + if let Some(proportional_data) = &self.proportional_editing_data { + let viewport_center = document.metadata().document_to_viewport.transform_point2(proportional_data.center); + let radius_viewport = document.metadata().document_to_viewport.transform_vector2(DVec2::X * proportional_data.radius as f64).x; + + overlay_context.circle(viewport_center, radius_viewport, Some(COLOR_OVERLAY_TRANSPARENT), Some(COLOR_OVERLAY_BLUE)); + } for layer in document.metadata().all_layers() { if !document.network_interface.is_artboard(&layer.to_node(), &[]) { @@ -350,6 +476,8 @@ impl MessageHandler> for TransformLayer } if final_transform { + self.proportional_editing_data = None; + self.initial_positions.clear(); responses.add(OverlaysMessage::RemoveProvider(TRANSFORM_GRS_OVERLAY_PROVIDER)); } } @@ -387,7 +515,10 @@ impl MessageHandler> for TransformLayer increments_key: INCREMENTS_KEY, }); } - TransformLayerMessage::BeginGRS { transform_type } => { + TransformLayerMessage::BeginGRS { + transform_type, + proportional_editing_data, + } => { let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect(); if (using_path_tool && selected_points.is_empty()) || (!using_path_tool && !using_select_tool && !using_pen_tool) @@ -396,11 +527,36 @@ impl MessageHandler> for TransformLayer { return; } - let Some(vector_data) = selected_layers.first().and_then(|&layer| document.network_interface.compute_modified_vector(layer)) else { selected.original_transforms.clear(); return; }; + self.proportional_editing_data = proportional_editing_data; + self.initial_positions.clear(); + + if let Some(_prop_data) = &self.proportional_editing_data { + // Store positions of all points in selected layers, not just affected points + for &layer in &selected_layers { + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + let layer_initial_positions = self.initial_positions.entry(layer).or_default(); + + // Get all selected points in this layer to exclude them + let selected_points: HashSet = shape_editor + .selected_points_in_layer(layer) + .map(|points| points.iter().filter_map(|p| p.as_anchor()).collect()) + .unwrap_or_default(); + + // Store point positions only for unselected points + for (i, &point_id) in vector_data.point_domain.ids().iter().enumerate() { + // Skip points that are selected by the user + if !selected_points.contains(&point_id) { + let pos_local = vector_data.point_domain.positions()[i]; + layer_initial_positions.insert(point_id, pos_local); + } + } + } + } + } if let [point] = selected_points.as_slice() { if matches!(point, ManipulatorPointId::Anchor(_)) { @@ -408,7 +564,13 @@ impl MessageHandler> for TransformLayer let handle1_length = handle1.length(&vector_data); let handle2_length = handle2.length(&vector_data); - if (handle1_length == 0. && handle2_length == 0. && !using_select_tool) || (handle1_length == f64::MAX && handle2_length == f64::MAX && !using_select_tool) { + // Check if proportional editing is enabled + let proportional_editing_enabled = self.proportional_editing_data.is_some(); + + // Only restrict R and S operations if proportional editing is not enabled + if !proportional_editing_enabled + && ((handle1_length == 0. && handle2_length == 0. && !using_select_tool) || (handle1_length == f64::MAX && handle2_length == f64::MAX && !using_select_tool)) + { // G should work for this point but not R and S if matches!(transform_type, TransformType::Rotate | TransformType::Scale) { selected.original_transforms.clear(); @@ -481,7 +643,8 @@ impl MessageHandler> for TransformLayer self.operation_count = 0; responses.add(ToolMessage::UpdateHints); } - + self.proportional_editing_data = None; + self.initial_positions.clear(); responses.add(OverlaysMessage::RemoveProvider(TRANSFORM_GRS_OVERLAY_PROVIDER)); } TransformLayerMessage::ConstrainX => { @@ -618,12 +781,76 @@ impl MessageHandler> for TransformLayer }; } + let pivot_vp = document_to_viewport.transform_point2(self.local_pivot); + let local_axis_transform_angle = (self.layer_bounding_box.0[1] - self.layer_bounding_box.0[0]).to_angle(); + + let total_transformation_vp = match self.transform_operation { + TransformOperation::Grabbing(translation) => { + let total_delta_doc = translation.to_dvec(self.initial_transform, self.increments); + let translate = DAffine2::from_translation(document_to_viewport.transform_vector2(total_delta_doc)); + if self.local { + let resolved_angle = if local_axis_transform_angle > 0. { + local_axis_transform_angle + } else { + local_axis_transform_angle - std::f64::consts::PI + }; + DAffine2::from_angle(resolved_angle) * translate * DAffine2::from_angle(-resolved_angle) + } else { + translate + } + } + TransformOperation::Rotating(rotation) => { + let total_angle = rotation.to_f64(self.increments); + let pivot_transform = DAffine2::from_translation(pivot_vp); + pivot_transform * DAffine2::from_angle(total_angle) * pivot_transform.inverse() + } + TransformOperation::Scaling(scale) => { + let total_scale_vec = scale.to_dvec(self.increments); + let pivot_transform = DAffine2::from_translation(pivot_vp); + + if self.local { + pivot_transform + * DAffine2::from_angle(local_axis_transform_angle) + * DAffine2::from_scale(total_scale_vec) + * DAffine2::from_angle(-local_axis_transform_angle) + * pivot_transform.inverse() + } else { + pivot_transform * DAffine2::from_scale(total_scale_vec) * pivot_transform.inverse() + } + } + TransformOperation::None => DAffine2::IDENTITY, + }; + + if let Some(prop_data) = &self.proportional_editing_data { + apply_proportionaling_edit(&self.initial_positions, prop_data, total_transformation_vp, &document.network_interface, document.metadata(), responses); + } + self.mouse_position = input.mouse.position; } TransformLayerMessage::SelectionChanged => { let target_layers = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect(); shape_editor.set_selected_layers(target_layers); } + TransformLayerMessage::TypeDecimalPoint => { + let pivot = document_to_viewport.transform_point2(self.local_pivot); + if self.transform_operation.can_begin_typing() { + self.transform_operation.grs_typed( + self.typing.type_decimal_point(), + &mut selected, + self.increments, + self.local, + self.layer_bounding_box, + document_to_viewport, + pivot, + self.initial_transform, + ); + + // Apply proportional editing + let total_transformation_vp = self.calculate_total_transformation_vp(document_to_viewport); + self.apply_proportional_editing(total_transformation_vp, document, responses); + } + } + TransformLayerMessage::TypeBackspace => { let pivot = document_to_viewport.transform_point2(self.local_pivot); if self.typing.digits.is_empty() && self.typing.negative { @@ -641,22 +868,12 @@ impl MessageHandler> for TransformLayer pivot, self.initial_transform, ); + + // Apply proportional editing + let total_transformation_vp = self.calculate_total_transformation_vp(document_to_viewport); + self.apply_proportional_editing(total_transformation_vp, document, responses); } - TransformLayerMessage::TypeDecimalPoint => { - let pivot = document_to_viewport.transform_point2(self.local_pivot); - if self.transform_operation.can_begin_typing() { - self.transform_operation.grs_typed( - self.typing.type_decimal_point(), - &mut selected, - self.increments, - self.local, - self.layer_bounding_box, - document_to_viewport, - pivot, - self.initial_transform, - ) - } - } + TransformLayerMessage::TypeDigit { digit } => { if self.transform_operation.can_begin_typing() { let pivot = document_to_viewport.transform_point2(self.local_pivot); @@ -669,7 +886,11 @@ impl MessageHandler> for TransformLayer document_to_viewport, pivot, self.initial_transform, - ) + ); + + // Calculate total transformation and apply proportional editing + let total_transformation_vp = self.calculate_total_transformation_vp(document_to_viewport); + self.apply_proportional_editing(total_transformation_vp, document, responses); } } TransformLayerMessage::TypeNegate => { @@ -687,7 +908,28 @@ impl MessageHandler> for TransformLayer document_to_viewport, pivot, self.initial_transform, - ) + ); + + // Apply proportional editing + let total_transformation_vp = self.calculate_total_transformation_vp(document_to_viewport); + self.apply_proportional_editing(total_transformation_vp, document, responses); + } + TransformLayerMessage::UpdateProportionalEditingData { data } => { + // Only update if we're in a transform operation with proportional editing + if let Some(current_proportional_data) = &mut self.proportional_editing_data { + // Update all fields from the new data + current_proportional_data.center = data.center; + current_proportional_data.affected_points = data.affected_points; + current_proportional_data.falloff_type = data.falloff_type; + current_proportional_data.radius = data.radius; + + // TODO: Essentialy a hack to trigger redraw for updated values + responses.add(TransformLayerMessage::PointerMove { + slow_key: SLOW_KEY, + increments_key: INCREMENTS_KEY, + }); + responses.add(OverlaysMessage::Draw); + } } } } @@ -708,6 +950,7 @@ impl MessageHandler> for TransformLayer TypeNegate, ConstrainX, ConstrainY, + UpdateProportionalEditingData ); common.extend(active); } @@ -797,7 +1040,7 @@ mod test_transform_layer { let final_translation = final_transform.translation; let original_translation = original_transform.translation; - // Verify transform is either restored to original OR reset to identity + // Verify transform is either restored to original or reset to identity assert!( (final_translation - original_translation).length() < 5. || final_translation.length() < 0.001, "Transform neither restored to original nor reset to identity. Original: {:?}, Final: {:?}", diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte index c976bf9c11..f6a6d1f2d5 100644 --- a/frontend/src/components/widgets/inputs/DropdownInput.svelte +++ b/frontend/src/components/widgets/inputs/DropdownInput.svelte @@ -21,16 +21,18 @@ export let interactive = true; export let disabled = false; export let tooltip: string | undefined = undefined; + export let minWidth = 0; let activeEntry = makeActiveEntry(); let activeEntrySkipWatcher = false; let initialSelectedIndex: number | undefined = undefined; let open = false; - let minWidth = 0; + let measuredMinWidth = 0; $: watchSelectedIndex(selectedIndex); $: watchActiveEntry(activeEntry); $: watchOpen(open); + $: minWidth = Math.max(minWidth, measuredMinWidth); function watchOpen(open: boolean) { initialSelectedIndex = open ? selectedIndex : undefined; @@ -94,7 +96,7 @@ (minWidth = detail)} + on:naturalWidth={({ detail }) => (measuredMinWidth = detail)} {activeEntry} on:activeEntry={({ detail }) => (activeEntry = detail)} on:hoverInEntry={({ detail }) => dispatchHoverInEntry(detail)} diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 2272069cc9..445401c777 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -1085,6 +1085,10 @@ export class DropdownInput extends WidgetProps { @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; + + // Styling + + minWidth!: number; } export class FontInput extends WidgetProps { diff --git a/node-graph/gcore/src/lib.rs b/node-graph/gcore/src/lib.rs index 1aa71eed73..13d4dacd07 100644 --- a/node-graph/gcore/src/lib.rs +++ b/node-graph/gcore/src/lib.rs @@ -13,7 +13,6 @@ pub use num_traits; #[cfg(feature = "reflections")] pub use ctor; - pub mod animation; pub mod consts; pub mod context; @@ -57,6 +56,9 @@ use core::any::TypeId; use core::pin::Pin; pub use dyn_any::{StaticTypeSized, WasmNotSend, WasmNotSync}; pub use memo::MemoHash; +// TODO: Perhaps build a wrapper util for Rng +pub use rand::{Rng, SeedableRng}; +pub use rand_chacha::ChaCha20Rng; pub use raster::Color; pub use types::Cow;