diff --git a/editor/src/consts.rs b/editor/src/consts.rs index cd13946d0d..920f34324c 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -59,6 +59,9 @@ pub const SELECTION_DRAG_ANGLE: f64 = 90.; pub const LAYER_ORIGIN_CROSS_DIAMETER: f64 = 10.; pub const LAYER_ORIGIN_CROSS_THICKNESS: f64 = 1.; +// GUIDES +pub const GUIDE_HIT_TOLERANCE: f64 = 5.; + // PIVOT pub const PIVOT_CROSSHAIR_THICKNESS: f64 = 1.; pub const PIVOT_CROSSHAIR_LENGTH: f64 = 9.; diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index cd4f7b72c3..e053a382a3 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -252,6 +252,7 @@ impl Dispatcher { menu_bar_message_handler.canvas_tilted = document.document_ptz.tilt() != 0.; menu_bar_message_handler.canvas_flipped = document.document_ptz.flip; menu_bar_message_handler.rulers_visible = document.rulers_visible; + menu_bar_message_handler.guides_visible = document.guide_handler.guides_visible; menu_bar_message_handler.node_graph_open = document.is_graph_overlay_open(); menu_bar_message_handler.has_selected_nodes = selected_nodes.selected_nodes().next().is_some(); menu_bar_message_handler.has_selected_layers = selected_nodes.selected_visible_layers(&document.network_interface).next().is_some(); @@ -262,6 +263,7 @@ impl Dispatcher { menu_bar_message_handler.canvas_tilted = false; menu_bar_message_handler.canvas_flipped = false; menu_bar_message_handler.rulers_visible = false; + menu_bar_message_handler.guides_visible = false; menu_bar_message_handler.node_graph_open = false; menu_bar_message_handler.has_selected_nodes = false; menu_bar_message_handler.has_selected_layers = false; diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index b52cc25907..9956833d57 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -1,6 +1,7 @@ use crate::messages::debug::utility_types::MessageLoggingVerbosity; use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::portfolio::document::guide_message::GuideMessage; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType}; use crate::messages::prelude::*; use graphene_std::path_bool::BooleanOperation; @@ -11,6 +12,7 @@ pub struct MenuBarMessageHandler { pub canvas_tilted: bool, pub canvas_flipped: bool, pub rulers_visible: bool, + pub guides_visible: bool, pub node_graph_open: bool, pub has_selected_nodes: bool, pub has_selected_layers: bool, @@ -616,6 +618,11 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleRulers)) .on_commit(|_| PortfolioMessage::ToggleRulers.into()) .disabled(no_active_document), + MenuListEntry::new("Guides") + .label("Guides") + .icon(if self.guides_visible { "CheckboxChecked" } else { "CheckboxUnchecked" }) + .on_commit(|_| GuideMessage::ToggleGuidesVisibility.into()) + .disabled(no_active_document), ], ]) .widget_instance(), diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index b6bc9b63ae..8747e3c4d2 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use super::utility_types::misc::{GroupFolderType, SnappingState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::portfolio::document::data_panel::DataPanelMessage; +use crate::messages::portfolio::document::guide_message::GuideMessage; use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping}; @@ -35,6 +36,8 @@ pub enum DocumentMessage { PropertiesPanel(PropertiesPanelMessage), #[child] DataPanel(DataPanelMessage), + #[child] + Guide(GuideMessage), // Messages AlignSelectedLayers { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index a0176d7902..b8cfd4a691 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -10,6 +10,7 @@ use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::guide_message_handler::{GuideMessageContext, GuideMessageHandler}; use crate::messages::portfolio::document::node_graph::NodeGraphMessageContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType; @@ -76,6 +77,8 @@ pub struct DocumentMessageHandler { pub properties_panel_message_handler: PropertiesPanelMessageHandler, #[serde(skip)] pub data_panel_message_handler: DataPanelMessageHandler, + #[serde(flatten)] + pub guide_handler: GuideMessageHandler, // ============================================ // Fields that are saved in the document format @@ -155,6 +158,7 @@ impl Default for DocumentMessageHandler { overlays_message_handler: OverlaysMessageHandler::default(), properties_panel_message_handler: PropertiesPanelMessageHandler::default(), data_panel_message_handler: DataPanelMessageHandler::default(), + guide_handler: GuideMessageHandler::default(), // ============================================ // Fields that are saved in the document format // ============================================ @@ -180,6 +184,7 @@ impl Default for DocumentMessageHandler { saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, + is_loaded: false, } } @@ -216,6 +221,14 @@ impl MessageHandler> for DocumentMes self.navigation_handler.process_message(message, responses, context); } + DocumentMessage::Guide(message) => { + let context = GuideMessageContext { + navigation_handler: &self.navigation_handler, + document_ptz: &self.document_ptz, + viewport, + }; + self.guide_handler.process_message(message, responses, context); + } DocumentMessage::Overlays(message) => { let visibility_settings = self.overlays_visibility_settings; @@ -622,6 +635,7 @@ impl MessageHandler> for DocumentMes self.snapping_state.grid_snapping = visible; responses.add(OverlaysMessage::Draw); } + // Guide messages DocumentMessage::GroupSelectedLayers { group_folder_type } => { responses.add(DocumentMessage::AddTransaction); @@ -1628,6 +1642,7 @@ impl MessageHandler> for DocumentMes ZoomCanvasTo200Percent, ZoomCanvasToFitAll, ); + common.extend(self.guide_handler.actions()); // Additional actions available on desktop #[cfg(not(target_family = "wasm"))] diff --git a/editor/src/messages/portfolio/document/guide_message.rs b/editor/src/messages/portfolio/document/guide_message.rs new file mode 100644 index 0000000000..6fb54c90ff --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_message.rs @@ -0,0 +1,14 @@ +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide::{GuideDirection, GuideId}; +use crate::messages::prelude::*; + +#[impl_message(Message, DocumentMessage, Guide)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum GuideMessage { + CreateGuide { id: GuideId, direction: GuideDirection, mouse_x: f64, mouse_y: f64 }, + MoveGuide { id: GuideId, mouse_x: f64, mouse_y: f64 }, + DeleteGuide { id: GuideId }, + GuideOverlays { context: OverlayContext }, + ToggleGuidesVisibility, + SetHoveredGuide { id: Option }, +} diff --git a/editor/src/messages/portfolio/document/guide_message_handler.rs b/editor/src/messages/portfolio/document/guide_message_handler.rs new file mode 100644 index 0000000000..b3ae51e1df --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_message_handler.rs @@ -0,0 +1,111 @@ +use super::utility_types::guide::{Guide, GuideDirection, GuideId}; +use crate::messages::portfolio::document::guide_message::{GuideMessage, GuideMessageDiscriminant}; +use crate::messages::portfolio::document::overlays::guide_overlays::guide_overlay; +use crate::messages::portfolio::document::utility_types::misc::PTZ; +use crate::messages::prelude::*; +use glam::DVec2; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)] +#[serde(default)] +pub struct GuideMessageHandler { + #[serde(default)] + pub guides: Vec, + #[serde(default = "default_guides_visible")] + pub guides_visible: bool, + #[serde(skip)] + pub hovered_guide_id: Option, +} + +fn default_guides_visible() -> bool { + true +} + +impl Default for GuideMessageHandler { + fn default() -> Self { + Self { + guides: Vec::new(), + guides_visible: true, + hovered_guide_id: None, + } + } +} + +#[derive(ExtractField)] +pub struct GuideMessageContext<'a> { + pub navigation_handler: &'a NavigationMessageHandler, + pub document_ptz: &'a PTZ, + pub viewport: &'a ViewportMessageHandler, +} + +#[message_handler_data] +impl MessageHandler> for GuideMessageHandler { + fn actions(&self) -> ActionList { + actions!(GuideMessageDiscriminant; ToggleGuidesVisibility) + } + + fn process_message(&mut self, message: GuideMessage, responses: &mut VecDeque, context: GuideMessageContext) { + let GuideMessageContext { + navigation_handler, + document_ptz, + viewport, + } = context; + + match message { + GuideMessage::CreateGuide { id, direction, mouse_x, mouse_y } => { + let document_to_viewport = navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), document_ptz); + let viewport_to_document = document_to_viewport.inverse(); + + let viewport_point = DVec2::new(mouse_x, mouse_y); + let document_point = viewport_to_document.transform_point2(viewport_point); + + let document_position = match direction { + GuideDirection::Horizontal => document_point.y, + GuideDirection::Vertical => document_point.x, + }; + + let guide = Guide::with_id(id, direction, document_position); + self.guides.push(guide); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideMessage::MoveGuide { id, mouse_x, mouse_y } => { + let document_to_viewport = navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), document_ptz); + let viewport_to_document = document_to_viewport.inverse(); + + let viewport_point = DVec2::new(mouse_x, mouse_y); + let document_point = viewport_to_document.transform_point2(viewport_point); + + if let Some(guide) = self.guides.iter_mut().find(|guide| guide.id == id) { + guide.position = match guide.direction { + GuideDirection::Horizontal => document_point.y, + GuideDirection::Vertical => document_point.x, + }; + } + responses.add(OverlaysMessage::Draw); + } + GuideMessage::DeleteGuide { id } => { + self.guides.retain(|g| g.id != id); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideMessage::GuideOverlays { context: mut overlay_context } => { + if self.guides_visible { + let document_to_viewport = navigation_handler.calculate_offset_transform(overlay_context.viewport.center_in_viewport_space().into(), document_ptz); + guide_overlay(self, &mut overlay_context, document_to_viewport); + } + } + GuideMessage::ToggleGuidesVisibility => { + self.guides_visible = !self.guides_visible; + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + responses.add(MenuBarMessage::SendLayout); + } + GuideMessage::SetHoveredGuide { id } => { + if self.hovered_guide_id != id { + self.hovered_guide_id = id; + responses.add(OverlaysMessage::Draw); + } + } + } + } +} diff --git a/editor/src/messages/portfolio/document/mod.rs b/editor/src/messages/portfolio/document/mod.rs index 767126b248..33c598e925 100644 --- a/editor/src/messages/portfolio/document/mod.rs +++ b/editor/src/messages/portfolio/document/mod.rs @@ -3,6 +3,8 @@ mod document_message_handler; pub mod data_panel; pub mod graph_operation; +pub mod guide_message; +pub mod guide_message_handler; pub mod navigation; pub mod node_graph; pub mod overlays; diff --git a/editor/src/messages/portfolio/document/overlays/guide_overlays.rs b/editor/src/messages/portfolio/document/overlays/guide_overlays.rs new file mode 100644 index 0000000000..cffc29a13d --- /dev/null +++ b/editor/src/messages/portfolio/document/overlays/guide_overlays.rs @@ -0,0 +1,57 @@ +use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50}; +use crate::messages::portfolio::document::guide_message_handler::GuideMessageHandler; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide::GuideDirection; +use glam::{DAffine2, DVec2}; + +fn extend_line_to_viewport(point: DVec2, direction: DVec2, viewport_size: DVec2) -> Option<(DVec2, DVec2)> { + let dir = direction.try_normalize()?; + + // Calculates t values for intersections with viewport edges + let mut t_values = Vec::new(); + + let edges = graphene_std::renderer::Quad::from_box([DVec2::ZERO, viewport_size]).all_edges(); + for [start, end] in edges { + let t_along_viewport = (point - start).perp_dot(dir) / (end - start).perp_dot(dir); + let t_along_direction = (point - start).perp_dot(end - start) / (end - start).perp_dot(dir); + if 0. <= t_along_viewport && t_along_viewport <= 1. && t_along_direction.is_finite() { + t_values.push(t_along_direction); + } + } + + if t_values.len() < 2 { + return None; + } + + let t_min = t_values.iter().cloned().fold(f64::INFINITY, f64::min); + let t_max = t_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + let start = point + dir * t_min; + let end = point + dir * t_max; + + Some((start, end)) +} + +pub fn guide_overlay(guide_handler: &GuideMessageHandler, overlay_context: &mut OverlayContext, document_to_viewport: DAffine2) { + let viewport_size: DVec2 = overlay_context.viewport.size().into(); + + for guide in &guide_handler.guides { + let (doc_point, doc_direction) = match guide.direction { + GuideDirection::Horizontal => (DVec2::new(0.0, guide.position), DVec2::X), + GuideDirection::Vertical => (DVec2::new(guide.position, 0.0), DVec2::Y), + }; + + let viewport_point = document_to_viewport.transform_point2(doc_point); + let viewport_direction = document_to_viewport.transform_vector2(doc_direction); + + let color = if guide_handler.hovered_guide_id == Some(guide.id) { + COLOR_OVERLAY_BLUE_50 + } else { + COLOR_OVERLAY_BLUE + }; + + if let Some((start, end)) = extend_line_to_viewport(viewport_point, viewport_direction, viewport_size) { + overlay_context.line(start, end, Some(color), None); + } + } +} diff --git a/editor/src/messages/portfolio/document/overlays/mod.rs b/editor/src/messages/portfolio/document/overlays/mod.rs index 4445dbfe84..2b2218096b 100644 --- a/editor/src/messages/portfolio/document/overlays/mod.rs +++ b/editor/src/messages/portfolio/document/overlays/mod.rs @@ -1,4 +1,5 @@ pub mod grid_overlays; +pub mod guide_overlays; mod overlays_message; mod overlays_message_handler; pub mod utility_functions; diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index 3bf436251c..6c015f5d6e 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -1,4 +1,5 @@ use super::utility_types::{OverlayProvider, OverlaysVisibilitySettings}; +use crate::messages::portfolio::document::guide_message::GuideMessage; use crate::messages::prelude::*; #[derive(ExtractField)] @@ -57,6 +58,13 @@ impl MessageHandler> for OverlaysMes viewport: *viewport, }, }); + responses.add(GuideMessage::GuideOverlays { + context: OverlayContext { + render_context: canvas_context.clone(), + visibility_settings: visibility_settings.clone(), + viewport: *viewport, + }, + }); for provider in &self.overlay_providers { responses.add(provider(OverlayContext { render_context: canvas_context.clone(), @@ -74,6 +82,7 @@ impl MessageHandler> for OverlaysMes if visibility_settings.all() { responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); + responses.add(GuideMessage::GuideOverlays { context: overlay_context.clone() }); for provider in &self.overlay_providers { responses.add(provider(overlay_context.clone())); diff --git a/editor/src/messages/portfolio/document/utility_types/guide.rs b/editor/src/messages/portfolio/document/utility_types/guide.rs new file mode 100644 index 0000000000..e19ddaf69e --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/guide.rs @@ -0,0 +1,61 @@ +use crate::application::generate_uuid; + +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct GuideId(u64); + +impl GuideId { + pub fn new() -> Self { + Self(generate_uuid()) + } + + pub fn from_raw(id: u64) -> Self { + Self(id) + } + + pub fn as_raw(&self) -> u64 { + self.0 + } +} + +impl Default for GuideId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum GuideDirection { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Guide { + pub id: GuideId, + pub direction: GuideDirection, + /// Position in document space (Y coordinate for horizontal guides, X coordinate for vertical guides) + pub position: f64, +} + +impl Guide { + pub fn new(direction: GuideDirection, position: f64) -> Self { + Self { + id: GuideId::new(), + direction, + position, + } + } + + pub fn with_id(id: GuideId, direction: GuideDirection, position: f64) -> Self { + Self { id, direction, position } + } + + pub fn horizontal(y: f64) -> Self { + Self::new(GuideDirection::Horizontal, y) + } + + pub fn vertical(x: f64) -> Self { + Self::new(GuideDirection::Vertical, x) + } +} diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index c474bf9665..535c405a7f 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -60,6 +60,7 @@ pub enum AlignAggregate { pub struct SnappingState { pub snapping_enabled: bool, pub grid_snapping: bool, + pub guides: bool, pub artboards: bool, pub tolerance: f64, pub bounding_box: BoundingBoxSnapping, @@ -72,6 +73,7 @@ impl Default for SnappingState { Self { snapping_enabled: true, grid_snapping: false, + guides: true, artboards: true, tolerance: 8., bounding_box: BoundingBoxSnapping::default(), @@ -103,6 +105,7 @@ impl SnappingState { }, SnapTarget::Artboard(_) => self.artboards, SnapTarget::Grid(_) => self.grid_snapping, + SnapTarget::Guide(_) => self.guides, SnapTarget::Alignment(AlignmentSnapTarget::AlignWithAnchorPoint) => self.path.align_with_anchor_point, SnapTarget::Alignment(_) => self.bounding_box.align_with_edges, SnapTarget::DistributeEvenly(_) => self.bounding_box.distribute_evenly, @@ -531,6 +534,23 @@ impl fmt::Display for GridSnapTarget { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuideSnapTarget { + Horizontal, + Vertical, + Intersection, +} + +impl fmt::Display for GuideSnapTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GuideSnapTarget::Horizontal => write!(f, "Guide: Horizontal"), + GuideSnapTarget::Vertical => write!(f, "Guide: Vertical"), + GuideSnapTarget::Intersection => write!(f, "Guide: Intersection"), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AlignmentSnapTarget { BoundingBoxCornerPoint, @@ -598,6 +618,7 @@ pub enum SnapTarget { Path(PathSnapTarget), Artboard(ArtboardSnapTarget), Grid(GridSnapTarget), + Guide(GuideSnapTarget), Alignment(AlignmentSnapTarget), DistributeEvenly(DistributionSnapTarget), } @@ -619,6 +640,7 @@ impl fmt::Display for SnapTarget { SnapTarget::Path(path_snap_target) => write!(f, "{path_snap_target}"), SnapTarget::Artboard(artboard_snap_target) => write!(f, "{artboard_snap_target}"), SnapTarget::Grid(grid_snap_target) => write!(f, "{grid_snap_target}"), + SnapTarget::Guide(guide_snap_target) => write!(f, "{guide_snap_target}"), SnapTarget::Alignment(alignment_snap_target) => write!(f, "{alignment_snap_target}"), SnapTarget::DistributeEvenly(distribution_snap_target) => write!(f, "{distribution_snap_target}"), } diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index 8bed0dbb85..6f2862f40b 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -1,6 +1,7 @@ pub mod clipboards; pub mod document_metadata; pub mod error; +pub mod guide; pub mod misc; pub mod network_interface; pub mod nodes; diff --git a/editor/src/messages/tool/common_functionality/snapping.rs b/editor/src/messages/tool/common_functionality/snapping.rs index 9b53cce626..e14fadba77 100644 --- a/editor/src/messages/tool/common_functionality/snapping.rs +++ b/editor/src/messages/tool/common_functionality/snapping.rs @@ -1,6 +1,7 @@ mod alignment_snapper; mod distribution_snapper; mod grid_snapper; +mod guide_snapper; mod layer_snapper; mod snap_results; @@ -19,6 +20,7 @@ use graphene_std::vector::PointId; use graphene_std::vector::algorithms::intersection::filtered_segment_intersections; use graphene_std::vector::misc::point_to_dvec2; pub use grid_snapper::*; +pub use guide_snapper::*; use kurbo::ParamCurve; pub use layer_snapper::*; pub use snap_results::*; @@ -39,6 +41,7 @@ pub struct SnapManager { indicator: Option, layer_snapper: LayerSnapper, grid_snapper: GridSnapper, + guide_snapper: GuideSnapper, alignment_snapper: AlignmentSnapper, distribution_snapper: DistributionSnapper, candidates: Option>, @@ -173,6 +176,10 @@ fn get_closest_intersection(snap_to: DVec2, curves: &[SnappedCurve]) -> Option Option { + get_line_intersection(snap_to, lines, SnapTarget::Grid(GridSnapTarget::Intersection)) +} + +pub fn get_line_intersection(snap_to: DVec2, lines: &[SnappedLine], target: SnapTarget) -> Option { let mut best = None; for line_i in lines { for line_j in lines { @@ -182,7 +189,7 @@ fn get_grid_intersection(snap_to: DVec2, lines: &[SnappedLine]) -> Option Vec<(DVec2, DVec2, GuideSnapTarget)> { + let document = snap_data.document; + let mut lines = Vec::new(); + + if !document.guide_handler.guides_visible || !document.snapping_state.guides { + return lines; + } + + for guide in &document.guide_handler.guides { + let (point, direction, snap_target) = match guide.direction { + GuideDirection::Horizontal => (DVec2::new(0.0, guide.position), DVec2::X, GuideSnapTarget::Horizontal), + GuideDirection::Vertical => (DVec2::new(guide.position, 0.0), DVec2::Y, GuideSnapTarget::Vertical), + }; + lines.push((point, direction, snap_target)); + } + + lines + } + + pub fn free_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults) { + let lines = self.get_snap_lines(snap_data); + let tolerance = snap_tolerance(snap_data.document); + + for (line_point, line_direction, snap_target) in lines { + let projected = (point.document_point - line_point).project_onto(line_direction) + line_point; + let distance = point.document_point.distance(projected); + + if !distance.is_finite() || distance > tolerance { + continue; + } + + let target = SnapTarget::Guide(snap_target); + if snap_data.document.snapping_state.target_enabled(target) { + snap_results.points.push(SnappedPoint { + snapped_point_document: projected, + source: point.source, + target, + source_bounds: point.quad, + distance, + tolerance, + ..Default::default() + }); + } + } + + let document = snap_data.document; + if document.snapping_state.target_enabled(SnapTarget::Guide(GuideSnapTarget::Intersection)) { + let tolerance = snap_tolerance(document); + let mut guide_lines: Vec = Vec::new(); + + for guide in &document.guide_handler.guides { + let (snapped_point_document, direction) = match guide.direction { + GuideDirection::Horizontal => (DVec2::new(0.0, guide.position), DVec2::X), + GuideDirection::Vertical => (DVec2::new(guide.position, 0.0), DVec2::Y), + }; + guide_lines.push(SnappedLine { + point: SnappedPoint { + snapped_point_document, + source: point.source, + tolerance, + ..Default::default() + }, + direction, + }); + } + + if let Some(intersection) = super::get_line_intersection(point.document_point, &guide_lines, SnapTarget::Guide(GuideSnapTarget::Intersection)) { + if intersection.distance <= tolerance { + snap_results.points.push(intersection); + } + } + } + } + + pub fn constrained_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults, constraint: SnapConstraint) { + let tolerance = snap_tolerance(snap_data.document); + let projected = constraint.projection(point.document_point); + let lines = self.get_snap_lines(snap_data); + + let (constraint_start, constraint_direction) = match constraint { + SnapConstraint::Line { origin, direction } => (origin, direction.normalize_or_zero()), + SnapConstraint::Direction(direction) => (projected, direction.normalize_or_zero()), + _ => { + warn!("Circle constraint not supported for guide snapping"); + return; + } + }; + + for (line_point, line_direction, snap_target) in lines { + let Some(intersection) = Quad::intersect_rays(line_point, line_direction, constraint_start, constraint_direction) else { + continue; + }; + + let distance = intersection.distance(point.document_point); + let target = SnapTarget::Guide(snap_target); + + if distance < tolerance && snap_data.document.snapping_state.target_enabled(target) { + snap_results.points.push(SnappedPoint { + snapped_point_document: intersection, + source: point.source, + target, + at_intersection: false, + constrained: true, + source_bounds: point.quad, + distance, + tolerance, + ..Default::default() + }); + } + } + } +} diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index e5738e7770..b31ccd1a40 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -4,9 +4,11 @@ use super::tool_prelude::*; use crate::consts::*; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::guide_message::GuideMessage; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::guide::{GuideDirection, GuideId}; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface, NodeTemplate}; use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; @@ -366,6 +368,10 @@ enum SelectToolFsmState { }, RotatingBounds, DraggingPivot, + DraggingGuide { + guide_id: GuideId, + direction: GuideDirection, + }, } impl Default for SelectToolFsmState { @@ -401,6 +407,9 @@ struct SelectToolData { selected_layers_changed: bool, snap_candidates: Vec, auto_panning: AutoPanning, + dragging_guide_id: Option, + dragging_guide_direction: Option, + guide_drag_start_position: Option, drag_start_center: ViewportPosition, } @@ -592,6 +601,37 @@ pub fn create_bounding_box_transform(document: &DocumentMessageHandler) -> DAffi .unwrap_or_default() } +fn hit_test_guide(document: &DocumentMessageHandler, viewport_position: DVec2, viewport: &ViewportMessageHandler) -> Option<(GuideId, GuideDirection)> { + if !document.guide_handler.guides_visible { + return None; + } + + let transform = document + .navigation_handler + .calculate_offset_transform(viewport.center_in_viewport_space().into(), &document.document_ptz); + + for guide in document.guide_handler.guides.iter().rev() { + let (doc_point, doc_direction) = match guide.direction { + GuideDirection::Horizontal => (DVec2::new(0.0, guide.position), DVec2::X), + GuideDirection::Vertical => (DVec2::new(guide.position, 0.0), DVec2::Y), + }; + + let viewport_point = transform.transform_point2(doc_point); + let viewport_direction = transform.transform_vector2(doc_direction); + + if let Some(dir_normalized) = viewport_direction.try_normalize() { + let to_mouse = viewport_position - viewport_point; + let perpendicular_dist = to_mouse.perp_dot(dir_normalized).abs(); + + if perpendicular_dist <= GUIDE_HIT_TOLERANCE { + return Some((guide.id, guide.direction)); + } + } + } + + None +} + impl Fsm for SelectToolFsmState { type ToolData = SelectToolData; type ToolOptions = (); @@ -1062,6 +1102,13 @@ impl Fsm for SelectToolFsmState { // tool_data.snap_manager.add_all_document_handles(document, input, &[], &[], &[]); state + } else if let Some((guide_id, direction)) = hit_test_guide(document, input.mouse.position, viewport) { + tool_data.dragging_guide_id = Some(guide_id); + tool_data.dragging_guide_direction = Some(direction); + + let original_position = document.guide_handler.guides.iter().find(|g| g.id == guide_id).map(|g| g.position); + tool_data.guide_drag_start_position = original_position; + SelectToolFsmState::DraggingGuide { guide_id, direction } } // Dragging one (or two, forming a corner) of the transform cage bounding box edges else if resize { @@ -1153,6 +1200,56 @@ impl Fsm for SelectToolFsmState { let selection = tool_data.nested_selection_behavior; SelectToolFsmState::Ready { selection } } + (SelectToolFsmState::DraggingGuide { .. }, SelectToolMessage::Abort) => { + tool_data.dragging_guide_id = None; + tool_data.dragging_guide_direction = None; + tool_data.guide_drag_start_position = None; + let selection = tool_data.nested_selection_behavior; + SelectToolFsmState::Ready { selection } + } + (SelectToolFsmState::DraggingGuide { guide_id, direction }, SelectToolMessage::PointerMove { .. }) => { + tool_data.drag_current = input.mouse.position; + + responses.add(GuideMessage::MoveGuide { + id: guide_id, + mouse_x: input.mouse.position.x, + mouse_y: input.mouse.position.y, + }); + + let cursor = match direction { + GuideDirection::Horizontal => MouseCursorIcon::NSResize, + GuideDirection::Vertical => MouseCursorIcon::EWResize, + }; + if tool_data.cursor != cursor { + tool_data.cursor = cursor; + responses.add(FrontendMessage::UpdateMouseCursor { cursor }); + } + + SelectToolFsmState::DraggingGuide { guide_id, direction } + } + (SelectToolFsmState::DraggingGuide { guide_id, direction: _ }, SelectToolMessage::DragStop { .. }) => { + tool_data.drag_current = input.mouse.position; + + // Checks if dragged outside viewport - deletes the guide + let viewport_size = viewport.size().into_dvec2(); + let outside_viewport = input.mouse.position.x < 0.0 || input.mouse.position.y < 0.0 || input.mouse.position.x > viewport_size.x || input.mouse.position.y > viewport_size.y; + + if outside_viewport { + responses.add(GuideMessage::DeleteGuide { id: guide_id }); + } else { + responses.add(GuideMessage::MoveGuide { + id: guide_id, + mouse_x: input.mouse.position.x, + mouse_y: input.mouse.position.y, + }); + } + + tool_data.dragging_guide_id = None; + tool_data.dragging_guide_direction = None; + tool_data.guide_drag_start_position = None; + let selection = tool_data.nested_selection_behavior; + SelectToolFsmState::Ready { selection } + } ( SelectToolFsmState::Dragging { axis, @@ -1327,6 +1424,18 @@ impl Fsm for SelectToolFsmState { cursor = MouseCursorIcon::Move; } + // Check if hovering over a guide and update hover state + let hovered_guide = hit_test_guide(document, input.mouse.position, viewport); + if let Some((guide_id, direction)) = hovered_guide { + cursor = match direction { + GuideDirection::Horizontal => MouseCursorIcon::NSResize, + GuideDirection::Vertical => MouseCursorIcon::EWResize, + }; + responses.add(GuideMessage::SetHoveredGuide { id: Some(guide_id) }); + } else { + responses.add(GuideMessage::SetHoveredGuide { id: None }); + } + // Generate the hover outline responses.add(OverlaysMessage::Draw); @@ -1774,6 +1883,13 @@ impl Fsm for SelectToolFsmState { let hint_data = HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]); hint_data.send_layout(responses); } + SelectToolFsmState::DraggingGuide { .. } => { + let hint_data = HintData(vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Guide")]), + ]); + hint_data.send_layout(responses); + } } } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 98722c4d76..f1f812847b 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -1,6 +1,8 @@ -
+
{#each svgTexts as svgText} diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 6877874007..2991311bdc 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -10,7 +10,9 @@ use editor::consts::FILE_EXTENSION; use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; +use editor::messages::portfolio::document::guide_message::GuideMessage; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use editor::messages::portfolio::document::utility_types::guide::{GuideDirection, GuideId}; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily}; use editor::messages::prelude::*; @@ -44,7 +46,18 @@ fn calculate_hash(t: &T) -> u64 { hasher.finish() } -/// Provides a handle to access the raw Wasm memory. +#[wasm_bindgen(js_name = setRandomSeed)] +pub fn set_random_seed(seed: u64) { + editor::application::set_uuid_seed(seed); +} + +/// Generates a unique guide ID +#[wasm_bindgen(js_name = generateGuideId)] +pub fn generate_guide_id() -> u64 { + editor::application::generate_uuid() +} + +/// Provides a handle to access the raw WASM memory. #[wasm_bindgen(js_name = wasmMemory)] pub fn wasm_memory() -> JsValue { wasm_bindgen::memory() @@ -882,6 +895,38 @@ impl EditorHandle { }; self.dispatch(message); } + + /// Create a new guide line from a ruler drag + #[wasm_bindgen(js_name = createGuide)] + pub fn create_guide(&self, id: u64, direction: String, mouse_x: f64, mouse_y: f64) { + let id = GuideId::from_raw(id); + let direction = match direction.as_str() { + "Horizontal" => GuideDirection::Horizontal, + "Vertical" => GuideDirection::Vertical, + _ => { + log::error!("Invalid guide direction: {}", direction); + return; + } + }; + let message = GuideMessage::CreateGuide { id, direction, mouse_x, mouse_y }; + self.dispatch(message); + } + + /// Move an existing guide to a new position + #[wasm_bindgen(js_name = moveGuide)] + pub fn move_guide(&self, id: u64, mouse_x: f64, mouse_y: f64) { + let id = GuideId::from_raw(id); + let message = GuideMessage::MoveGuide { id, mouse_x, mouse_y }; + self.dispatch(message); + } + + /// Delete a guide by its ID + #[wasm_bindgen(js_name = deleteGuide)] + pub fn delete_guide(&self, id: u64) { + let id = GuideId::from_raw(id); + let message = GuideMessage::DeleteGuide { id }; + self.dispatch(message); + } } // ============================================================================