From 1e109dc5527a0eb3b2da5113c20ba49fbdb2a4c1 Mon Sep 17 00:00:00 2001 From: 0HyperCube <78500760+0HyperCube@users.noreply.github.com> Date: Tue, 30 Aug 2022 22:36:33 +0100 Subject: [PATCH] Add a pivot widget to selected layer(s) to control the origin(s) (#772) * Add pivot * Add dragging pivot * Cleanup * Remove tabs * Fix multiplication order * Restyle pivot * Add move cursor icon * Update pivot size * Code review tweaks * Fix alt with non-centred pivot * Comment for add one * Pivot sets layer panel origin * Tweek alt centre thing * Fix division by zero case * Add pivot dots to properties * FIx some typos Co-authored-by: Keavon Chambers --- editor/src/consts.rs | 3 + editor/src/messages/frontend/utility_types.rs | 1 + .../utility_types/widgets/assist_widgets.rs | 52 ++++- .../properties_panel_message.rs | 2 + .../properties_panel_message_handler.rs | 7 + .../properties_panel/utility_functions.rs | 35 ++-- .../messages/tool/common_functionality/mod.rs | 1 + .../tool/common_functionality/pivot.rs | 177 ++++++++++++++++++ .../transformation_cage.rs | 42 ++++- .../tool/tool_messages/select_tool.rs | 65 +++++-- frontend/src/wasm-communication/messages.ts | 1 + graphene/src/document.rs | 7 +- graphene/src/operation.rs | 4 + 13 files changed, 360 insertions(+), 37 deletions(-) create mode 100644 editor/src/messages/tool/common_functionality/pivot.rs diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 3124a337..54f79adb 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -43,6 +43,9 @@ pub const BIG_NUDGE_AMOUNT: f64 = 10.; // Select tool pub const SELECTION_TOLERANCE: f64 = 5.; pub const SELECTION_DRAG_ANGLE: f64 = 90.; +pub const PIVOT_OUTER_OUTLINE_THICKNESS: f64 = 1.; +pub const PIVOT_OUTER: f64 = 9.; +pub const PIVOT_INNER: f64 = 3.; // Transformation cage pub const BOUNDS_SELECT_THRESHOLD: f64 = 10.; diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index aa5e53fa..4ffe1ce7 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -25,6 +25,7 @@ pub enum MouseCursorIcon { Grabbing, Crosshair, Text, + Move, NSResize, EWResize, NESWResize, diff --git a/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs b/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs index 8535f993..1eb41a90 100644 --- a/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs @@ -1,4 +1,5 @@ use derivative::*; +use glam::DVec2; use serde::{Deserialize, Serialize}; use crate::messages::layout::utility_types::layout_widget::WidgetCallback; @@ -14,7 +15,7 @@ pub struct PivotAssist { pub on_update: WidgetCallback, } -#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] pub enum PivotPosition { #[default] None, @@ -46,3 +47,52 @@ impl From<&str> for PivotPosition { } } } + +impl From for Option { + fn from(input: PivotPosition) -> Self { + match input { + PivotPosition::None => None, + PivotPosition::TopLeft => Some(DVec2::new(0., 0.)), + PivotPosition::TopCenter => Some(DVec2::new(0.5, 0.)), + PivotPosition::TopRight => Some(DVec2::new(1., 0.)), + PivotPosition::CenterLeft => Some(DVec2::new(0., 0.5)), + PivotPosition::Center => Some(DVec2::new(0.5, 0.5)), + PivotPosition::CenterRight => Some(DVec2::new(1., 0.5)), + PivotPosition::BottomLeft => Some(DVec2::new(0., 1.)), + PivotPosition::BottomCenter => Some(DVec2::new(0.5, 1.)), + PivotPosition::BottomRight => Some(DVec2::new(1., 1.)), + } + } +} + +impl From for PivotPosition { + fn from(input: DVec2) -> Self { + const TOLERANCE: f64 = 1e-5_f64; + if input.y.abs() < TOLERANCE { + if input.x.abs() < TOLERANCE { + return PivotPosition::TopLeft; + } else if (input.x - 0.5).abs() < TOLERANCE { + return PivotPosition::TopCenter; + } else if (input.x - 1.).abs() < TOLERANCE { + return PivotPosition::TopRight; + } + } else if (input.y - 0.5).abs() < TOLERANCE { + if input.x.abs() < TOLERANCE { + return PivotPosition::CenterLeft; + } else if (input.x - 0.5).abs() < TOLERANCE { + return PivotPosition::Center; + } else if (input.x - 1.).abs() < TOLERANCE { + return PivotPosition::CenterRight; + } + } else if (input.y - 1.).abs() < TOLERANCE { + if input.x.abs() < TOLERANCE { + return PivotPosition::BottomLeft; + } else if (input.x - 0.5).abs() < TOLERANCE { + return PivotPosition::BottomCenter; + } else if (input.x - 1.).abs() < TOLERANCE { + return PivotPosition::BottomRight; + } + } + PivotPosition::None + } +} diff --git a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message.rs b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message.rs index 210f1503..ff2ef43a 100644 --- a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message.rs +++ b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message.rs @@ -1,4 +1,5 @@ use super::utility_types::TransformOp; +use crate::messages::layout::utility_types::widgets::assist_widgets::PivotPosition; use crate::messages::portfolio::document::utility_types::misc::TargetDocument; use crate::messages::prelude::*; @@ -25,5 +26,6 @@ pub enum PropertiesPanelMessage { ModifyTransform { value: f64, transform_op: TransformOp }, ResendActiveProperties, SetActiveLayers { paths: Vec>, document: TargetDocument }, + SetPivot { new_position: PivotPosition }, UpdateSelectedDocumentProperties, } diff --git a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs index d40bd269..9e945930 100644 --- a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs @@ -101,6 +101,13 @@ impl<'a> MessageHandler { + let (layer_path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + let position: Option = new_position.into(); + let pivot = position.unwrap().into(); + + responses.push_back(Operation::SetPivot { layer_path, pivot }.into()); + } CheckSelectedWasUpdated { path } => { if self.matches_selected(&path) { responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into()) diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs index 7e508fca..516bd996 100644 --- a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs +++ b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs @@ -1,6 +1,7 @@ use super::utility_types::TransformOp; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; use crate::messages::layout::utility_types::misc::LayoutTarget; +use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist; use crate::messages::layout::utility_types::widgets::button_widgets::PopoverButton; use crate::messages::layout::utility_types::widgets::input_widgets::{ColorInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput}; use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, IconStyle, Separator, SeparatorDirection, SeparatorType, TextLabel}; @@ -80,6 +81,7 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ } else { panic!("Artboard must have a solid fill") }; + let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache)); vec![LayoutGroup::Section { name: "Artboard".into(), @@ -95,12 +97,12 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.transform.x()), + value: Some(layer.transform.x() + pivot.x), label: "X".into(), unit: " px".into(), - on_update: WidgetCallback::new(|number_input: &NumberInput| { + on_update: WidgetCallback::new(move |number_input: &NumberInput| { PropertiesPanelMessage::ModifyTransform { - value: number_input.value.unwrap(), + value: number_input.value.unwrap() - pivot.x, transform_op: TransformOp::X, } .into() @@ -112,12 +114,12 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.transform.y()), + value: Some(layer.transform.y() + pivot.y), label: "Y".into(), unit: " px".into(), - on_update: WidgetCallback::new(|number_input: &NumberInput| { + on_update: WidgetCallback::new(move |number_input: &NumberInput| { PropertiesPanelMessage::ModifyTransform { - value: number_input.value.unwrap(), + value: number_input.value.unwrap() - pivot.y, transform_op: TransformOp::Y, } .into() @@ -308,6 +310,7 @@ pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque } fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup { + let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache)); LayoutGroup::Section { name: "Transform".into(), layout: vec![ @@ -317,17 +320,25 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup value: "Location".into(), ..TextLabel::default() })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::PivotAssist(PivotAssist { + position: layer.pivot.into(), + on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| PropertiesPanelMessage::SetPivot { new_position: pivot_assist.position }.into()), + })), WidgetHolder::new(Widget::Separator(Separator { separator_type: SeparatorType::Unrelated, direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.transform.x()), + value: Some(layer.transform.x() + pivot.x), label: "X".into(), unit: " px".into(), - on_update: WidgetCallback::new(|number_input: &NumberInput| { + on_update: WidgetCallback::new(move |number_input: &NumberInput| { PropertiesPanelMessage::ModifyTransform { - value: number_input.value.unwrap(), + value: number_input.value.unwrap() - pivot.x, transform_op: TransformOp::X, } .into() @@ -339,12 +350,12 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.transform.y()), + value: Some(layer.transform.y() + pivot.y), label: "Y".into(), unit: " px".into(), - on_update: WidgetCallback::new(|number_input: &NumberInput| { + on_update: WidgetCallback::new(move |number_input: &NumberInput| { PropertiesPanelMessage::ModifyTransform { - value: number_input.value.unwrap(), + value: number_input.value.unwrap() - pivot.y, transform_op: TransformOp::Y, } .into() diff --git a/editor/src/messages/tool/common_functionality/mod.rs b/editor/src/messages/tool/common_functionality/mod.rs index ac51b310..611f1baa 100644 --- a/editor/src/messages/tool/common_functionality/mod.rs +++ b/editor/src/messages/tool/common_functionality/mod.rs @@ -1,5 +1,6 @@ pub mod overlay_renderer; pub mod path_outline; +pub mod pivot; pub mod resize; pub mod shape_editor; pub mod snapping; diff --git a/editor/src/messages/tool/common_functionality/pivot.rs b/editor/src/messages/tool/common_functionality/pivot.rs new file mode 100644 index 00000000..5cf4c697 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/pivot.rs @@ -0,0 +1,177 @@ +//! Handler for the pivot overlay visible on the selected layer(s) whilst using the Select tool which controls the center of rotation/scale and origin of the layer. + +use crate::application::generate_uuid; +use crate::consts::{COLOR_ACCENT, PIVOT_INNER, PIVOT_OUTER, PIVOT_OUTER_OUTLINE_THICKNESS}; +use crate::messages::layout::utility_types::widgets::assist_widgets::PivotPosition; +use crate::messages::prelude::*; + +use graphene::layers::style; +use graphene::layers::text_layer::FontCache; +use graphene::{LayerId, Operation}; + +use glam::{DAffine2, DVec2}; +use std::collections::VecDeque; + +#[derive(Clone, Debug)] +pub struct Pivot { + /// Pivot between (0,0) and (1,1) + normalized_pivot: DVec2, + /// Transform to get from normalized pivot to viewspace + transform_from_normalized: DAffine2, + /// The viewspace pivot position (if applicable) + pivot: Option, + /// A reference to the previous overlays so we can destroy them + pivot_overlay_circles: Option<[Vec; 2]>, + /// The old pivot position in the GUI, used to reduce refreshes of the document bar + old_pivot_position: PivotPosition, +} + +impl Default for Pivot { + fn default() -> Self { + Self { + normalized_pivot: DVec2::splat(0.5), + transform_from_normalized: Default::default(), + pivot: Default::default(), + pivot_overlay_circles: Default::default(), + old_pivot_position: PivotPosition::Center, + } + } +} + +impl Pivot { + /// Calculates the transform that gets from normalized pivot to viewspace. + fn get_layer_pivot_transform(layer_path: &[LayerId], layer: &graphene::layers::layer_info::Layer, document: &DocumentMessageHandler, font_cache: &FontCache) -> DAffine2 { + let [min, max] = layer.aabb_for_transform(DAffine2::IDENTITY, font_cache).unwrap_or([DVec2::ZERO, DVec2::ONE]); + let bounds_transform = DAffine2::from_translation(min) * DAffine2::from_scale(max - min); + let layer_transform = document.graphene_document.multiply_transforms(layer_path).unwrap_or(DAffine2::IDENTITY); + layer_transform * bounds_transform + } + + /// Recomputes the pivot position and transform. + fn recalculate_pivot(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache) { + let mut layers = document.selected_visible_layers(); + if let Some(first) = layers.next() { + // Add one because the first item is consumed above. + let selected_layers_count = layers.count() + 1; + + // If just one layer is selected we can use its inner transform + if selected_layers_count == 1 { + if let Ok(layer) = document.graphene_document.layer(first) { + self.normalized_pivot = layer.pivot; + self.transform_from_normalized = Self::get_layer_pivot_transform(first, layer, document, font_cache); + self.pivot = Some(self.transform_from_normalized.transform_point2(layer.pivot)); + } + } else { + // If more than one layer is selected we use the AABB with the mean of the pivots + let xy_summation = document + .selected_visible_layers() + .filter_map(|path| document.graphene_document.pivot(path, font_cache)) + .reduce(|a, b| a + b) + .unwrap_or_default(); + + let pivot = xy_summation / selected_layers_count as f64; + self.pivot = Some(pivot); + let [min, max] = document.selected_visible_layers_bounding_box(font_cache).unwrap_or([DVec2::ZERO, DVec2::ONE]); + self.normalized_pivot = (pivot - min) / (max - min); + + self.transform_from_normalized = DAffine2::from_translation(min) * DAffine2::from_scale(max - min); + } + } else { + // If no layers are selected then we revert things back to default + self.normalized_pivot = DVec2::splat(0.5); + self.pivot = None; + } + } + + pub fn clear_overlays(&mut self, responses: &mut VecDeque) { + if let Some(overlays) = self.pivot_overlay_circles.take() { + for path in overlays { + responses.push_back(DocumentMessage::Overlays(Operation::DeleteLayer { path }.into()).into()); + } + } + } + + fn redraw_pivot(&mut self, responses: &mut VecDeque) { + self.clear_overlays(responses); + + let pivot = match self.pivot { + Some(pivot) => pivot, + None => return, + }; + + let layer_paths = [vec![generate_uuid()], vec![generate_uuid()]]; + responses.push_back( + DocumentMessage::Overlays( + Operation::AddEllipse { + path: layer_paths[0].clone(), + transform: DAffine2::IDENTITY.to_cols_array(), + style: style::PathStyle::new(Some(style::Stroke::new(COLOR_ACCENT, PIVOT_OUTER_OUTLINE_THICKNESS)), style::Fill::Solid(graphene::color::Color::WHITE)), + insert_index: -1, + } + .into(), + ) + .into(), + ); + responses.push_back( + DocumentMessage::Overlays( + Operation::AddEllipse { + path: layer_paths[1].clone(), + transform: DAffine2::IDENTITY.to_cols_array(), + style: style::PathStyle::new(None, style::Fill::Solid(COLOR_ACCENT)), + insert_index: -1, + } + .into(), + ) + .into(), + ); + + self.pivot_overlay_circles = Some(layer_paths.clone()); + let [outer, inner] = layer_paths; + + let pivot_diameter_without_outline = PIVOT_OUTER - PIVOT_OUTER_OUTLINE_THICKNESS; + let transform = DAffine2::from_scale_angle_translation(DVec2::splat(pivot_diameter_without_outline), 0., pivot - DVec2::splat(pivot_diameter_without_outline / 2.)).to_cols_array(); + responses.push_back(DocumentMessage::Overlays(Operation::TransformLayerInViewport { path: outer, transform }.into()).into()); + + let transform = DAffine2::from_scale_angle_translation(DVec2::splat(PIVOT_INNER), 0., pivot - DVec2::splat(PIVOT_INNER / 2.)).to_cols_array(); + responses.push_back(DocumentMessage::Overlays(Operation::TransformLayerInViewport { path: inner, transform }.into()).into()); + } + + pub fn update_pivot(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque) { + self.recalculate_pivot(document, font_cache); + self.redraw_pivot(responses); + } + + /// Answers if the pivot widget has changed (so we should refresh the tool bar at the top of the canvas). + pub fn should_refresh_pivot_position(&mut self) -> bool { + let new = self.to_pivot_position(); + let should_refresh = new != self.old_pivot_position; + self.old_pivot_position = new; + should_refresh + } + + pub fn to_pivot_position(&self) -> PivotPosition { + self.normalized_pivot.into() + } + + /// Sets the viewport position of the pivot for all selected layers. + pub fn set_viewport_position(&self, position: DVec2, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque) { + for layer_path in document.selected_visible_layers() { + if let Ok(layer) = document.graphene_document.layer(layer_path) { + let transform = Self::get_layer_pivot_transform(layer_path, layer, document, font_cache); + let pivot = transform.inverse().transform_point2(position).into(); + let layer_path = layer_path.to_owned(); + responses.push_back(Operation::SetPivot { layer_path, pivot }.into()); + } + } + } + + /// Set the pivot using the normalized transform that is set above. + pub fn set_normalized_position(&self, position: DVec2, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque) { + self.set_viewport_position(self.transform_from_normalized.transform_point2(position), document, font_cache, responses); + } + + /// Answers if the pointer is currently positioned over the pivot. + pub fn is_over(&self, mouse: DVec2) -> bool { + self.pivot.filter(|&pivot| mouse.distance_squared(pivot) < (PIVOT_OUTER / 2.).powi(2)).is_some() + } +} diff --git a/editor/src/messages/tool/common_functionality/transformation_cage.rs b/editor/src/messages/tool/common_functionality/transformation_cage.rs index 613dc41b..0f914519 100644 --- a/editor/src/messages/tool/common_functionality/transformation_cage.rs +++ b/editor/src/messages/tool/common_functionality/transformation_cage.rs @@ -81,19 +81,33 @@ impl SelectedEdges { let mut pivot = self.pivot_from_bounds(min, max); if center { + // The below ratio is: `dragging edge / being centred`. + // The `is_finite()` checks are in case the user is dragging the edge where the pivot is located (in which case the centering mode is ignored). if self.top { - max.y = center_around.y * 2. - min.y; - pivot.y = center_around.y; + let ratio = (center_around.y - min.y) / (center_around.y - self.bounds[0].y); + if ratio.is_finite() { + max.y = center_around.y + ratio * (self.bounds[1].y - center_around.y); + pivot.y = center_around.y; + } } else if self.bottom { - min.y = center_around.y * 2. - max.y; - pivot.y = center_around.y; + let ratio = (max.y - center_around.y) / (self.bounds[1].y - center_around.y); + if ratio.is_finite() { + min.y = center_around.y - ratio * (center_around.y - self.bounds[0].y); + pivot.y = center_around.y; + } } if self.left { - max.x = center_around.x * 2. - min.x; - pivot.x = center_around.x; + let ratio = (center_around.x - min.x) / (center_around.x - self.bounds[0].x); + if ratio.is_finite() { + max.x = center_around.x + ratio * (self.bounds[1].x - center_around.x); + pivot.x = center_around.x; + } } else if self.right { - min.x = center_around.x * 2. - max.x; - pivot.x = center_around.x; + let ratio = (max.x - center_around.x) / (self.bounds[1].x - center_around.x); + if ratio.is_finite() { + min.x = center_around.x - ratio * (center_around.x - self.bounds[0].x); + pivot.x = center_around.x; + } } } @@ -115,8 +129,16 @@ impl SelectedEdges { } /// Calculates the required scaling to resize the bounding box - pub fn bounds_to_scale_transform(&self, size: DVec2) -> DAffine2 { - DAffine2::from_scale(size / (self.bounds[1] - self.bounds[0])) + pub fn bounds_to_scale_transform(&self, position: DVec2, size: DVec2) -> (DAffine2, DVec2) { + let enlargement_factor = size / (self.bounds[1] - self.bounds[0]); + let mut pivot = (self.bounds[0] * enlargement_factor - position) / (enlargement_factor - DVec2::splat(1.)); + if pivot.x.is_nan() { + pivot.x = 0.; + } + if pivot.y.is_nan() { + pivot.y = 0.; + } + (DAffine2::from_scale(enlargement_factor), pivot) } } diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 2371b3c7..fa8365f9 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -4,6 +4,7 @@ use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion}; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, PropertyHolder, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; +use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::layout::utility_types::widgets::assist_widgets::{PivotAssist, PivotPosition}; use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton}; use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType}; @@ -11,6 +12,7 @@ use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, use crate::messages::portfolio::document::utility_types::transformation::Selected; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::path_outline::*; +use crate::messages::tool::common_functionality::pivot::Pivot; use crate::messages::tool::common_functionality::snapping::{self, SnapManager}; use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType}; @@ -34,7 +36,7 @@ pub struct SelectTool { #[remain::sorted] #[impl_message(Message, ToolMessage, Select)] -#[derive(PartialEq, Eq, Clone, Debug, Hash, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub enum SelectToolMessage { // Standard messages #[remain::unsorted] @@ -62,6 +64,9 @@ pub enum SelectToolMessage { center: Key, duplicate: Key, }, + SetPivot { + position: PivotPosition, + }, } impl ToolMetadata for SelectTool { @@ -252,12 +257,8 @@ impl PropertyHolder for SelectTool { })), // We'd like this widget to hide and show itself whenever the transformation cage is active or inactive (i.e. when no layers are selected) WidgetHolder::new(Widget::PivotAssist(PivotAssist { - position: PivotPosition::Center, - on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| { - // TODO: Make this actually do something - log::debug!("Changed pivot to {:?}", pivot_assist.position); - Message::NoOp - }), + position: self.tool_data.pivot.to_pivot_position(), + on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| SelectToolMessage::SetPivot { position: pivot_assist.position }.into()), })), ], }])) @@ -278,6 +279,11 @@ impl<'a> MessageHandler> for SelectTool { let new_state = self.fsm_state.transition(message, &mut self.tool_data, tool_data, &(), responses); + if self.tool_data.pivot.should_refresh_pivot_position() { + // Notify the frontend about the updated pivot position (a bit ugly to do it here not in the fsm but that doesn't have SelectTool) + self.register_properties(responses, LayoutTarget::ToolOptions); + } + if self.fsm_state != new_state { self.fsm_state = new_state; self.fsm_state.update_hints(responses); @@ -321,6 +327,7 @@ enum SelectToolFsmState { DrawingBox, ResizingBounds, RotatingBounds, + DraggingPivot, } impl Default for SelectToolFsmState { @@ -340,6 +347,7 @@ struct SelectToolData { bounding_box_overlays: Option, snap_manager: SnapManager, cursor: MouseCursorIcon, + pivot: Pivot, } impl SelectToolData { @@ -393,6 +401,7 @@ impl Fsm for SelectToolFsmState { tool_data.path_outlines.update_selected(document.selected_visible_layers(), document, responses, font_cache); tool_data.path_outlines.intersect_test_hovered(input, document, responses, font_cache); + tool_data.pivot.update_pivot(document, font_cache, responses); self } @@ -455,7 +464,12 @@ impl Fsm for SelectToolFsmState { // If the user clicks on a layer that is in their current selection, go into the dragging mode. // If the user clicks on new shape, make that layer their new selection. // Otherwise enter the box select mode - let state = if let Some(selected_edges) = dragging_bounds { + let state = if tool_data.pivot.is_over(input.mouse.position) { + tool_data.snap_manager.start_snap(document, document.bounding_boxes(None, None, font_cache), true, true); + tool_data.snap_manager.add_all_document_handles(document, &[], &[], &[]); + + DraggingPivot + } else if let Some(selected_edges) = dragging_bounds { let snap_x = selected_edges.2 || selected_edges.3; let snap_y = selected_edges.0 || selected_edges.1; @@ -565,12 +579,11 @@ impl Fsm for SelectToolFsmState { let snapped_mouse_position = tool_data.snap_manager.snap_position(responses, document, mouse_position); - let (_, size) = movement.new_size(snapped_mouse_position, bounds.transform, center, bounds.center_of_transformation, axis_align); - let delta = movement.bounds_to_scale_transform(size); + let (position, size) = movement.new_size(snapped_mouse_position, bounds.transform, center, bounds.center_of_transformation, axis_align); + let (delta, mut pivot) = movement.bounds_to_scale_transform(position, size); let selected = &tool_data.layers_dragging.iter().collect::>(); - let pivot = if center { &mut bounds.center_of_transformation } else { &mut bounds.opposite_pivot }; - let mut selected = Selected::new(&mut bounds.original_transforms, pivot, selected, responses, &document.graphene_document); + let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, selected, responses, &document.graphene_document); selected.update_transforms(delta); } @@ -603,6 +616,13 @@ impl Fsm for SelectToolFsmState { RotatingBounds } + (DraggingPivot, PointerMove { .. }) => { + let mouse_position = input.mouse.position; + let snapped_mouse_position = tool_data.snap_manager.snap_position(responses, document, mouse_position); + tool_data.pivot.set_viewport_position(snapped_mouse_position, document, font_cache, responses); + + DraggingPivot + } (DrawingBox, PointerMove { .. }) => { tool_data.drag_current = input.mouse.position; @@ -619,7 +639,12 @@ impl Fsm for SelectToolFsmState { DrawingBox } (Ready, PointerMove { .. }) => { - let cursor = tool_data.bounding_box_overlays.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true)); + let mut cursor = tool_data.bounding_box_overlays.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true)); + + // Dragging the pivot overrules the other operations + if tool_data.pivot.is_over(input.mouse.position) { + cursor = MouseCursorIcon::Move; + } // Generate the select outline (but not if the user is going to use the bound overlays) if cursor == MouseCursorIcon::Default { @@ -660,6 +685,11 @@ impl Fsm for SelectToolFsmState { Ready } + (DraggingPivot, DragStop) => { + tool_data.snap_manager.cleanup(responses); + + Ready + } (DrawingBox, DragStop) => { let quad = tool_data.selection_quad(); responses.push_front( @@ -684,6 +714,7 @@ impl Fsm for SelectToolFsmState { responses.push_back(DocumentMessage::Undo.into()); tool_data.path_outlines.clear_selected(responses); + tool_data.pivot.clear_overlays(responses); Ready } @@ -708,6 +739,7 @@ impl Fsm for SelectToolFsmState { tool_data.path_outlines.clear_hovered(responses); tool_data.path_outlines.clear_selected(responses); + tool_data.pivot.clear_overlays(responses); tool_data.snap_manager.cleanup(responses); Ready @@ -727,6 +759,12 @@ impl Fsm for SelectToolFsmState { self } + (_, SetPivot { position }) => { + let pos: Option = position.into(); + tool_data.pivot.set_normalized_position(pos.unwrap(), document, font_cache, responses); + + self + } _ => self, } } else { @@ -869,6 +907,7 @@ impl Fsm for SelectToolFsmState { label: String::from("Snap 15°"), plus: false, }])]), + SelectToolFsmState::DraggingPivot => HintData(vec![]), }; responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into()); diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 3057a763..a83056b9 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -195,6 +195,7 @@ const mouseCursorIconCSSNames = { Grabbing: "grabbing", Crosshair: "crosshair", Text: "text", + Move: "move", NSResize: "ns-resize", EWResize: "ew-resize", NESWResize: "nesw-resize", diff --git a/graphene/src/document.rs b/graphene/src/document.rs index 126cd98a..cbfee30c 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -760,7 +760,12 @@ impl Document { self.mark_as_dirty(&path)?; Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat()) } - + Operation::SetPivot { layer_path, pivot } => { + let layer = self.layer_mut(&layer_path).expect("Setting pivot for invalid layer"); + layer.pivot = pivot.into(); + self.mark_as_dirty(&layer_path)?; + Some([vec![DocumentChanged, LayerChanged { path: layer_path.clone() }], update_thumbnails_upstream(&layer_path)].concat()) + } Operation::SetLayerTransformInViewport { path, transform } => { let transform = DAffine2::from_cols_array(&transform); self.set_transform_relative_to_viewport(&path, transform)?; diff --git a/graphene/src/operation.rs b/graphene/src/operation.rs index 8ca84ced..78d4d359 100644 --- a/graphene/src/operation.rs +++ b/graphene/src/operation.rs @@ -56,6 +56,10 @@ pub enum Operation { blob_url: String, dimensions: (f64, f64), }, + SetPivot { + layer_path: Vec, + pivot: (f64, f64), + }, SetTextEditability { path: Vec, editable: bool,