diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index cfffefefe2..261197d425 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -258,6 +258,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.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(); @@ -268,6 +269,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 b06b7397a7..43464e838c 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -11,6 +11,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, @@ -614,6 +615,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(|_| DocumentMessage::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..9962296b08 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -5,6 +5,7 @@ use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::portfolio::document::data_panel::DataPanelMessage; 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::guide::{GuideDirection, GuideId}; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping}; use crate::messages::portfolio::utility_types::PanelType; use crate::messages::prelude::*; @@ -82,6 +83,28 @@ pub enum DocumentMessage { GridVisibility { visible: bool, }, + // Guide messages + 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, + }, GroupSelectedLayers { group_folder_type: GroupFolderType, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 6402f609fd..c3665f2289 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1,6 +1,7 @@ use super::node_graph::document_node_definitions; use super::node_graph::utility_types::Transform; use super::utility_types::error::EditorError; +use super::utility_types::guide::{Guide, GuideId}; use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BOXES, SNAP_FUNCTIONS_FOR_PATHS, SnappingOptions, SnappingState}; use super::utility_types::network_interface::{self, NodeNetworkInterface, TransactionStatus}; use super::utility_types::nodes::{CollapsedLayers, SelectedNodes}; @@ -138,6 +139,18 @@ pub struct DocumentMessageHandler { /// If the user clicks or Ctrl-clicks one layer, it becomes the start of the range selection and then Shift-clicking another layer selects all layers between the start and end. #[serde(skip)] layer_range_selection_reference: Option, + /// List of horizontal guide lines in document space. + #[serde(default)] + pub horizontal_guides: Vec, + /// List of vertical guide lines in document space. + #[serde(default)] + pub vertical_guides: Vec, + /// Whether guide lines are visible in the viewport. + #[serde(default = "default_guides_visible")] + pub guides_visible: bool, + /// ID of the currently hovered guide for visual feedback. + #[serde(skip)] + pub hovered_guide_id: Option, /// Whether or not the editor has executed the network to render the document yet. If this is opened as an inactive tab, it won't be loaded initially because the active tab is prioritized. #[serde(skip)] pub is_loaded: bool, @@ -179,6 +192,10 @@ impl Default for DocumentMessageHandler { saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, + horizontal_guides: Vec::new(), + vertical_guides: Vec::new(), + guides_visible: true, + hovered_guide_id: None, is_loaded: false, } } @@ -619,6 +636,70 @@ impl MessageHandler> for DocumentMes self.snapping_state.grid_snapping = visible; responses.add(OverlaysMessage::Draw); } + // Guide messages + DocumentMessage::CreateGuide { id, direction, mouse_x, mouse_y } => { + // Calculates the document-to-viewport transform with offset + let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.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 { + super::utility_types::guide::GuideDirection::Horizontal => document_point.y, + super::utility_types::guide::GuideDirection::Vertical => document_point.x, + }; + + let guide = Guide::with_id(id, direction, document_position); + match direction { + super::utility_types::guide::GuideDirection::Horizontal => self.horizontal_guides.push(guide), + super::utility_types::guide::GuideDirection::Vertical => self.vertical_guides.push(guide), + } + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + DocumentMessage::MoveGuide { id, mouse_x, mouse_y } => { + // Calculate the document-to-viewport transform with offset + let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.document_ptz); + let viewport_to_document = document_to_viewport.inverse(); + + // Transform the full mouse viewport position to document space + let viewport_point = DVec2::new(mouse_x, mouse_y); + let document_point = viewport_to_document.transform_point2(viewport_point); + + // Search in both guide lists and update the position + if let Some(guide) = self.horizontal_guides.iter_mut().find(|guide| guide.id == id) { + guide.position = document_point.y; + } else if let Some(guide) = self.vertical_guides.iter_mut().find(|guide| guide.id == id) { + guide.position = document_point.x; + } + responses.add(OverlaysMessage::Draw); + } + DocumentMessage::DeleteGuide { id } => { + // Remove from horizontal guides + self.horizontal_guides.retain(|g| g.id != id); + // Remove from vertical guides + self.vertical_guides.retain(|g| g.id != id); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + DocumentMessage::GuideOverlays { context: mut overlay_context } => { + if self.guides_visible { + super::overlays::guide_overlays::guide_overlay(self, &mut overlay_context); + } + } + DocumentMessage::ToggleGuidesVisibility => { + self.guides_visible = !self.guides_visible; + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + responses.add(MenuBarMessage::SendLayout); + } + DocumentMessage::SetHoveredGuide { id } => { + if self.hovered_guide_id != id { + self.hovered_guide_id = id; + responses.add(OverlaysMessage::Draw); + } + } DocumentMessage::GroupSelectedLayers { group_folder_type } => { responses.add(DocumentMessage::AddTransaction); @@ -2991,6 +3072,10 @@ fn default_document_network_interface() -> NodeNetworkInterface { network_interface } +fn default_guides_visible() -> bool { + true +} + /// Targets for the [`ClickXRayIter`]. In order to reduce computation, we prefer just a point/path test where possible. #[derive(Clone)] enum XRayTarget { 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..56de57a37c --- /dev/null +++ b/editor/src/messages/portfolio/document/overlays/guide_overlays.rs @@ -0,0 +1,100 @@ +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::prelude::DocumentMessageHandler; +use glam::DVec2; + +const GUIDE_COLOR: &str = "#00BFFF"; +const GUIDE_HOVER_COLOR: &str = "#FF6600"; + +fn extend_line_to_viewport(point: DVec2, direction: DVec2, viewport_size: DVec2) -> Option<(DVec2, DVec2)> { + if direction.length_squared() < f64::EPSILON { + return None; + } + + let dir = direction.normalize(); + + // Calculates t values for intersections with viewport edges + let mut t_values = Vec::new(); + + if dir.x.abs() > f64::EPSILON { + let t = -point.x / dir.x; + let y = point.y + t * dir.y; + if y >= 0.0 && y <= viewport_size.y { + t_values.push(t); + } + } + + // Right edge (x = viewport_size.x) + if dir.x.abs() > f64::EPSILON { + let t = (viewport_size.x - point.x) / dir.x; + let y = point.y + t * dir.y; + if y >= 0.0 && y <= viewport_size.y { + t_values.push(t); + } + } + + // Top edge (y = 0) + if dir.y.abs() > f64::EPSILON { + let t = -point.y / dir.y; + let x = point.x + t * dir.x; + if x >= 0.0 && x <= viewport_size.x { + t_values.push(t); + } + } + + // Bottom edge (y = viewport_size.y) + if dir.y.abs() > f64::EPSILON { + let t = (viewport_size.y - point.y) / dir.y; + let x = point.x + t * dir.x; + if x >= 0.0 && x <= viewport_size.x { + t_values.push(t); + } + } + + 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(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { + let document_to_viewport = document + .navigation_handler + .calculate_offset_transform(overlay_context.viewport.center_in_viewport_space().into(), &document.document_ptz); + + let viewport_size: DVec2 = overlay_context.viewport.size().into(); + + for guide in &document.horizontal_guides { + let doc_point = DVec2::new(0.0, guide.position); + let doc_direction = DVec2::X; // Horizontal guides run in the X direction in document space + + let viewport_point = document_to_viewport.transform_point2(doc_point); + let viewport_direction = document_to_viewport.transform_vector2(doc_direction); + + let color = if document.hovered_guide_id == Some(guide.id) { GUIDE_HOVER_COLOR } else { GUIDE_COLOR }; + + if let Some((start, end)) = extend_line_to_viewport(viewport_point, viewport_direction, viewport_size) { + overlay_context.line(start, end, Some(color), None); + } + } + + for guide in &document.vertical_guides { + let doc_point = DVec2::new(guide.position, 0.0); + let doc_direction = 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 document.hovered_guide_id == Some(guide.id) { GUIDE_HOVER_COLOR } else { GUIDE_COLOR }; + + 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..b7b14c4b20 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -57,6 +57,13 @@ impl MessageHandler> for OverlaysMes viewport: *viewport, }, }); + responses.add(DocumentMessage::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 +81,7 @@ impl MessageHandler> for OverlaysMes if visibility_settings.all() { responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); + responses.add(DocumentMessage::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.guides_visible || !document.snapping_state.guides { + return lines; + } + + for guide in &document.horizontal_guides { + lines.push((DVec2::new(0.0, guide.position), DVec2::X, GuideSnapTarget::Horizontal)); + } + + for guide in &document.vertical_guides { + lines.push((DVec2::new(guide.position, 0.0), DVec2::Y, GuideSnapTarget::Vertical)); + } + + 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.horizontal_guides { + guide_lines.push(SnappedLine { + point: SnappedPoint { + snapped_point_document: DVec2::new(0.0, guide.position), + source: point.source, + tolerance, + ..Default::default() + }, + direction: DVec2::X, + }); + } + + for guide in &document.vertical_guides { + guide_lines.push(SnappedLine { + point: SnappedPoint { + snapped_point_document: DVec2::new(guide.position, 0.0), + source: point.source, + tolerance, + ..Default::default() + }, + direction: DVec2::Y, + }); + } + + // Reuse the generic intersection finder from snapping module + 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 608da0bc89..61589ee279 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -6,6 +6,7 @@ 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::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; @@ -365,6 +366,10 @@ enum SelectToolFsmState { }, RotatingBounds, DraggingPivot, + DraggingGuide { + guide_id: GuideId, + direction: GuideDirection, + }, } impl Default for SelectToolFsmState { @@ -400,6 +405,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, } @@ -591,6 +599,56 @@ 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)> { + const HIT_TOLERANCE: f64 = 5.0; + + if !document.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.horizontal_guides.iter().rev() { + let doc_point = DVec2::new(0.0, guide.position); + let doc_direction = DVec2::X; + + let viewport_point = transform.transform_point2(doc_point); + let viewport_direction = transform.transform_vector2(doc_direction); + + if viewport_direction.length_squared() > f64::EPSILON { + let dir_normalized = viewport_direction.normalize(); + let to_mouse = viewport_position - viewport_point; + let perpendicular_dist = (to_mouse.x * dir_normalized.y - to_mouse.y * dir_normalized.x).abs(); + + if perpendicular_dist <= HIT_TOLERANCE { + return Some((guide.id, GuideDirection::Horizontal)); + } + } + } + + for guide in document.vertical_guides.iter().rev() { + let doc_point = DVec2::new(guide.position, 0.0); + let doc_direction = DVec2::Y; + + let viewport_point = transform.transform_point2(doc_point); + let viewport_direction = transform.transform_vector2(doc_direction); + + if viewport_direction.length_squared() > f64::EPSILON { + let dir_normalized = viewport_direction.normalize(); + let to_mouse = viewport_position - viewport_point; + let perpendicular_dist = (to_mouse.x * dir_normalized.y - to_mouse.y * dir_normalized.x).abs(); + + if perpendicular_dist <= HIT_TOLERANCE { + return Some((guide.id, GuideDirection::Vertical)); + } + } + } + + None +} + impl Fsm for SelectToolFsmState { type ToolData = SelectToolData; type ToolOptions = (); @@ -1058,6 +1116,16 @@ 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 = match direction { + GuideDirection::Horizontal => document.horizontal_guides.iter().find(|g| g.id == guide_id).map(|g| g.position), + GuideDirection::Vertical => document.vertical_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 { @@ -1149,6 +1217,60 @@ impl Fsm for SelectToolFsmState { let selection = tool_data.nested_selection_behavior; SelectToolFsmState::Ready { selection } } + // Guide dragging - abort + (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 } + } + // Guide dragging - pointer move + (SelectToolFsmState::DraggingGuide { guide_id, direction }, SelectToolMessage::PointerMove { .. }) => { + tool_data.drag_current = input.mouse.position; + + // MoveGuide expects viewport coordinates and does the conversion internally + responses.add(DocumentMessage::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(DocumentMessage::DeleteGuide { id: guide_id }); + } else { + // MoveGuide expects viewport coordinates and does the conversion internally + responses.add(DocumentMessage::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, @@ -1323,6 +1445,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(DocumentMessage::SetHoveredGuide { id: Some(guide_id) }); + } else { + responses.add(DocumentMessage::SetHoveredGuide { id: None }); + } + // Generate the hover outline responses.add(OverlaysMessage::Draw); @@ -1770,6 +1904,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 18f5f5d74e..e3d2977758 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 8f386c3879..002a756da8 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -11,6 +11,7 @@ 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::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, Platform}; use editor::messages::prelude::*; @@ -50,6 +51,12 @@ 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 { @@ -869,6 +876,38 @@ impl EditorHandle { }; self.dispatch(message); } + + /// Create a new guide line from a ruler drag with direction: "Horizontal" or "Vertical" + #[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 = DocumentMessage::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 = DocumentMessage::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 = DocumentMessage::DeleteGuide { id }; + self.dispatch(message); + } } // ============================================================================