Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Compass rose translation gizmo in the Select tool's transform cage
![Image](https://github.com/user-attachments/assets/d57b08f6-a3b7-46b0-ac0e-9fd1cd94aff9)

1. The rotation/scaling grip/handle squares should rotate with the layer, not with the viewport. Notice how the layer has a local rotation and the grips use that rotation.
2. We want to put a ring in the center that can be dragged to translate the transform cage. This is important for non-filled shapes which have no selection area inside them. In the image below, you'll see the click target in translucent blue. That light blue should appear when it or the solid ring is hovered. (Inside the ring is not a click target though, since it needs to give space for the pivot widget to be draggable.)
3. That ring should have local-axis constrained drag arrows. Either side of the arrow (left/right; or up/down) is equivalent. Similar functionality to `G` `X` `X` or `G` `Y` `Y` but without actually being in G/R/S mode and no seeing the number overlays, etc. But we do want an overlay that is just a dashed line through the dragged arrow points going through the entire viewport. It should b either red or green to match the arrow being dragged. Collectively we call the ring and arrows the "compass rose" if you want to use that for your variable names.

![Image](https://github.com/user-attachments/assets/f5da9467-38ef-44cf-9027-b7e016a1c8c8)

![Image](https://github.com/user-attachments/assets/8e808382-a5dc-4b50-8f94-934ac6b560b9)

Discussion in Discord [here](https://discord.com/channels/731730685944922173/931942323644928040/1338450472050229281) with exact specs and other details and chatter.

---

Possible follow-on work that's somewhat related: #2225


Discussion / hints:

Fix patch:
diff --git a/editor/Cargo.toml b/editor/Cargo.toml
index 499703b2be..042d9eeeb1 100644
--- a/editor/Cargo.toml
+++ b/editor/Cargo.toml
@@ -2,7 +2,7 @@
 name = "graphite-editor"
 publish = false
 version = "0.0.0"
-rust-version = "1.79"
+rust-version = "1.82"
 authors = ["Graphite Authors <contact@graphite.rs>"]
 edition = "2021"
 readme = "../README.md"
diff --git a/editor/src/consts.rs b/editor/src/consts.rs
index e4bb2cc126..c10a8d8615 100644
--- a/editor/src/consts.rs
+++ b/editor/src/consts.rs
@@ -54,10 +54,20 @@ pub const DEFAULT_STROKE_WIDTH: f64 = 2.;
 pub const SELECTION_TOLERANCE: f64 = 5.;
 pub const DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD: f64 = 15.;
 pub const SELECTION_DRAG_ANGLE: f64 = 90.;
+
+// PIVOT
 pub const PIVOT_CROSSHAIR_THICKNESS: f64 = 1.;
 pub const PIVOT_CROSSHAIR_LENGTH: f64 = 9.;
 pub const PIVOT_DIAMETER: f64 = 5.;
 
+// COMPASS ROSE
+pub const COMPASS_ROSE_RING_INNER_DIAMETER: f64 = 13.;
+pub const COMPASS_ROSE_MAIN_RING_DIAMETER: f64 = 15.;
+pub const COMPASS_ROSE_HOVER_RING_DIAMETER: f64 = 23.;
+pub const COMPASS_ROSE_ARROW_SIZE: f64 = 5.;
+// Angle to either side of the compass arrows where they are targetted by the cursor (in degrees, must be less than 45°)
+pub const COMPASS_ROSE_ARROW_CLICK_TARGET_ANGLE: f64 = 20.;
+
 // TRANSFORM OVERLAY
 pub const ANGLE_MEASURE_RADIUS_FACTOR: f64 = 0.04;
 pub const ARC_MEASURE_RADIUS_FACTOR_RANGE: (f64, f64) = (0.05, 0.15);
@@ -108,7 +118,6 @@ pub const COLOR_OVERLAY_RED: &str = "#ef5454";
 pub const COLOR_OVERLAY_GRAY: &str = "#cccccc";
 pub const COLOR_OVERLAY_WHITE: &str = "#ffffff";
 pub const COLOR_OVERLAY_LABEL_BACKGROUND: &str = "#000000cc";
-pub const COLOR_OVERLAY_TRANSPARENT: &str = "#ffffff00";
 
 // DOCUMENT
 pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs
index a16eda48c4..125a1ed091 100644
--- a/editor/src/messages/portfolio/document/overlays/utility_types.rs
+++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs
@@ -1,6 +1,7 @@
 use super::utility_functions::overlay_canvas_context;
 use crate::consts::{
-	COLOR_OVERLAY_BLUE, COLOR_OVERLAY_TRANSPARENT, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
+	COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER,
+	COMPASS_ROSE_RING_INNER_DIAMETER, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
 };
 use crate::messages::prelude::Message;
 
@@ -9,7 +10,7 @@ use graphene_core::renderer::Quad;
 use graphene_std::vector::{PointId, SegmentId, VectorData};
 
 use core::borrow::Borrow;
-use core::f64::consts::TAU;
+use core::f64::consts::{FRAC_PI_2, TAU};
 use glam::{DAffine2, DVec2};
 use std::collections::HashMap;
 use wasm_bindgen::JsValue;
@@ -294,9 +295,15 @@ impl OverlayContext {
 
 	pub fn draw_scale(&mut self, start: DVec2, scale: f64, radius: f64, text: &str) {
 		let sign = scale.signum();
+		let mut fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_WHITE.strip_prefix('#').unwrap())
+			.unwrap()
+			.with_alpha(0.05)
+			.rgba_hex();
+		fill_color.insert(0, '#');
+		let fill_color = Some(fill_color.as_str());
 		self.line(start + DVec2::X * radius * sign, start + DVec2::X * (radius * scale), None);
-		self.circle(start, radius, Some(COLOR_OVERLAY_TRANSPARENT), None);
-		self.circle(start, radius * scale.abs(), Some(COLOR_OVERLAY_TRANSPARENT), None);
+		self.circle(start, radius, fill_color, None);
+		self.circle(start, radius * scale.abs(), fill_color, None);
 		self.text(
 			text,
 			COLOR_OVERLAY_BLUE,
@@ -307,7 +314,77 @@ impl OverlayContext {
 		)
 	}
 
-	pub fn pivot(&mut self, position: DVec2) {
+	pub fn compass_rose(&mut self, compass_center: DVec2, angle: f64, show_compass_with_hover_ring: Option<bool>) {
+		const HOVER_RING_OUTER_RADIUS: f64 = COMPASS_ROSE_HOVER_RING_DIAMETER / 2.;
+		const MAIN_RING_OUTER_RADIUS: f64 = COMPASS_ROSE_MAIN_RING_DIAMETER / 2.;
+		const MAIN_RING_INNER_RADIUS: f64 = COMPASS_ROSE_RING_INNER_DIAMETER / 2.;
+		const ARROW_RADIUS: f64 = COMPASS_ROSE_ARROW_SIZE / 2.;
+		const HOVER_RING_STROKE_WIDTH: f64 = HOVER_RING_OUTER_RADIUS - MAIN_RING_INNER_RADIUS;
+		const HOVER_RING_CENTERLINE_RADIUS: f64 = (HOVER_RING_OUTER_RADIUS + MAIN_RING_INNER_RADIUS) / 2.;
+		const MAIN_RING_STROKE_WIDTH: f64 = MAIN_RING_OUTER_RADIUS - MAIN_RING_INNER_RADIUS;
+		const MAIN_RING_CENTERLINE_RADIUS: f64 = (MAIN_RING_OUTER_RADIUS + MAIN_RING_INNER_RADIUS) / 2.;
+
+		let Some(show_hover_ring) = show_compass_with_hover_ring else { return };
+
+		self.start_dpi_aware_transform();
+
+		let center = compass_center.round() - DVec2::splat(0.5);
+
+		// Save the old line width to restore it later
+		let old_line_width = self.render_context.line_width();
+
+		// Hover ring
+		if show_hover_ring {
+			let mut fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()).unwrap().with_alpha(0.5).rgba_hex();
+			fill_color.insert(0, '#');
+
+			self.render_context.set_line_width(HOVER_RING_STROKE_WIDTH);
+			self.render_context.begin_path();
+			self.render_context.arc(center.x, center.y, HOVER_RING_CENTERLINE_RADIUS, 0., TAU).expect("Failed to draw hover ring");
+			self.render_context.set_stroke_style_str(&fill_color);
+			self.render_context.stroke();
+		}
+
+		// Arrows
+		self.render_context.set_line_width(0.01);
+		for i in 0..4 {
+			let direction = DVec2::from_angle(i as f64 * FRAC_PI_2 + angle);
+			let color = if i % 2 == 0 { COLOR_OVERLAY_RED } else { COLOR_OVERLAY_GREEN };
+
+			let tip = center + direction * HOVER_RING_OUTER_RADIUS;
+			let base = center + direction * (MAIN_RING_INNER_RADIUS + MAIN_RING_OUTER_RADIUS) / 2.;
+
+			let r = (ARROW_RADIUS.powi(2) + MAIN_RING_INNER_RADIUS.powi(2)).sqrt();
+			let (cos, sin) = (MAIN_RING_INNER_RADIUS / r, ARROW_RADIUS / r);
+			let side1 = center + r * DVec2::new(cos * direction.x - sin * direction.y, sin * direction.x + direction.y * cos);
+			let side2 = center + r * DVec2::new(cos * direction.x + sin * direction.y, -sin * direction.x + direction.y * cos);
+
+			self.render_context.begin_path();
+			self.render_context.move_to(tip.x, tip.y);
+			self.render_context.line_to(side1.x, side1.y);
+			self.render_context.line_to(base.x, base.y);
+			self.render_context.line_to(side2.x, side2.y);
+			self.render_context.close_path();
+
+			self.render_context.set_fill_style_str(color);
+			self.render_context.fill();
+			self.render_context.set_stroke_style_str(color);
+			self.render_context.stroke();
+		}
+
+		// Main ring
+		self.render_context.set_line_width(MAIN_RING_STROKE_WIDTH);
+		self.render_context.begin_path();
+		self.render_context.arc(center.x, center.y, MAIN_RING_CENTERLINE_RADIUS, 0., TAU).expect("Failed to draw main ring");
+		self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
+		self.render_context.stroke();
+
+		// Restore the old line width
+		self.render_context.set_line_width(old_line_width);
+	}
+
+	pub fn pivot(&mut self, position: DVec2, angle: f64) {
+		let uv = DVec2::from_angle(angle);
 		let (x, y) = (position.round() - DVec2::splat(0.5)).into();
 
 		self.start_dpi_aware_transform();
@@ -322,19 +399,19 @@ impl OverlayContext {
 		// Crosshair
 
 		// Round line caps add half the stroke width to the length on each end, so we subtract that here before halving to get the radius
-		let crosshair_radius = (PIVOT_CROSSHAIR_LENGTH - PIVOT_CROSSHAIR_THICKNESS) / 2.;
+		const CROSSHAIR_RADIUS: f64 = (PIVOT_CROSSHAIR_LENGTH - PIVOT_CROSSHAIR_THICKNESS) / 2.;
 
 		self.render_context.set_stroke_style_str(COLOR_OVERLAY_YELLOW);
 		self.render_context.set_line_cap("round");
 
 		self.render_context.begin_path();
-		self.render_context.move_to(x - crosshair_radius, y);
-		self.render_context.line_to(x + crosshair_radius, y);
+		self.render_context.move_to(x + CROSSHAIR_RADIUS * uv.x, y + CROSSHAIR_RADIUS * uv.y);
+		self.render_context.line_to(x - CROSSHAIR_RADIUS * uv.x, y - CROSSHAIR_RADIUS * uv.y);
 		self.render_context.stroke();
 
 		self.render_context.begin_path();
-		self.render_context.move_to(x, y - crosshair_radius);
-		self.render_context.line_to(x, y + crosshair_radius);
+		self.render_context.move_to(x - CROSSHAIR_RADIUS * uv.y, y + CROSSHAIR_RADIUS * uv.x);
+		self.render_context.line_to(x + CROSSHAIR_RADIUS * uv.y, y - CROSSHAIR_RADIUS * uv.x);
 		self.render_context.stroke();
 
 		self.render_context.set_line_cap("butt");
diff --git a/editor/src/messages/tool/common_functionality/compass_rose.rs b/editor/src/messages/tool/common_functionality/compass_rose.rs
new file mode 100644
index 0000000000..be7af7ca79
--- /dev/null
+++ b/editor/src/messages/tool/common_functionality/compass_rose.rs
@@ -0,0 +1,85 @@
+use crate::consts::{COMPASS_ROSE_ARROW_CLICK_TARGET_ANGLE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER};
+use crate::messages::prelude::DocumentMessageHandler;
+
+use glam::{DAffine2, DVec2};
+use std::f64::consts::FRAC_PI_2;
+
+#[derive(Clone, Default, Debug)]
+pub struct CompassRose {
+	compass_center: DVec2,
+}
+
+impl CompassRose {
+	pub fn refresh_transform(&mut self, document: &DocumentMessageHandler) {
+		let [min, max] = document.selected_visible_and_unlock_layers_bounding_box_viewport().unwrap_or([DVec2::ZERO, DVec2::ONE]);
+		self.compass_center = (DAffine2::from_translation(min) * DAffine2::from_scale(max - min)).transform_point2(DVec2::splat(0.5));
+	}
+
+	pub fn compass_rose_position(&self) -> DVec2 {
+		self.compass_center
+	}
+
+	pub fn compass_rose_state(&self, mouse: DVec2, angle: f64) -> CompassRoseState {
+		const COMPASS_ROSE_RING_INNER_RADIUS_SQUARED: f64 = (COMPASS_ROSE_RING_INNER_DIAMETER / 2.) * (COMPASS_ROSE_RING_INNER_DIAMETER / 2.);
+		const COMPASS_ROSE_HOVER_RING_RADIUS_SQUARED: f64 = (COMPASS_ROSE_HOVER_RING_DIAMETER / 2.) * (COMPASS_ROSE_HOVER_RING_DIAMETER / 2.);
+
+		let compass_distance_squared = mouse.distance_squared(self.compass_center);
+
+		if !(COMPASS_ROSE_RING_INNER_RADIUS_SQUARED..COMPASS_ROSE_HOVER_RING_RADIUS_SQUARED).contains(&compass_distance_squared) {
+			return CompassRoseState::None;
+		}
+
+		let angle = (mouse - self.compass_center).angle_to(DVec2::from_angle(angle)).abs();
+		let resolved_angle = (FRAC_PI_2 - angle).abs();
+		let angular_width = COMPASS_ROSE_ARROW_CLICK_TARGET_ANGLE.to_radians();
+
+		if resolved_angle < angular_width {
+			CompassRoseState::AxisY
+		} else if resolved_angle > (FRAC_PI_2 - angular_width) {
+			CompassRoseState::AxisX
+		} else {
+			CompassRoseState::Ring
+		}
+	}
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum CompassRoseState {
+	Ring,
+	AxisX,
+	AxisY,
+	None,
+}
+
+impl CompassRoseState {
+	pub fn can_grab(&self) -> bool {
+		matches!(self, Self::Ring | Self::AxisX | Self::AxisY)
+	}
+
+	pub fn is_ring(&self) -> bool {
+		matches!(self, Self::Ring)
+	}
+
+	pub fn axis_type(&self) -> Option<Axis> {
+		match self {
+			CompassRoseState::AxisX => Some(Axis::X),
+			CompassRoseState::AxisY => Some(Axis::Y),
+			CompassRoseState::Ring => Some(Axis::None),
+			_ => None,
+		}
+	}
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
+pub enum Axis {
+	#[default]
+	None,
+	X,
+	Y,
+}
+
+impl Axis {
+	pub fn is_constraint(&self) -> bool {
+		matches!(self, Self::X | Self::Y)
+	}
+}
diff --git a/editor/src/messages/tool/common_functionality/mod.rs b/editor/src/messages/tool/common_functionality/mod.rs
index e3e273fa2e..9bc2236061 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 auto_panning;
 pub mod color_selector;
+pub mod compass_rose;
 pub mod graph_modification_utils;
 pub mod measure;
 pub mod pivot;
diff --git a/editor/src/messages/tool/common_functionality/pivot.rs b/editor/src/messages/tool/common_functionality/pivot.rs
index c56f12127e..2c924602fb 100644
--- a/editor/src/messages/tool/common_functionality/pivot.rs
+++ b/editor/src/messages/tool/common_functionality/pivot.rs
@@ -83,10 +83,10 @@ impl Pivot {
 		}
 	}
 
-	pub fn update_pivot(&mut self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) {
+	pub fn update_pivot(&mut self, document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, angle: f64) {
 		self.recalculate_pivot(document);
 		if let Some(pivot) = self.pivot {
-			overlay_context.pivot(pivot);
+			overlay_context.pivot(pivot, angle);
 		}
 	}
 
diff --git a/editor/src/messages/tool/common_functionality/transformation_cage.rs b/editor/src/messages/tool/common_functionality/transformation_cage.rs
index 811b1c2df4..da129c9b9a 100644
--- a/editor/src/messages/tool/common_functionality/transformation_cage.rs
+++ b/editor/src/messages/tool/common_functionality/transformation_cage.rs
@@ -1,6 +1,6 @@
 use crate::consts::{
-	BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, MAXIMUM_ALT_SCALE_FACTOR, MIN_LENGTH_FOR_CORNERS_VISIBILITY, MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR,
-	SELECTION_DRAG_ANGLE,
+	BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, COLOR_OVERLAY_WHITE, MAXIMUM_ALT_SCALE_FACTOR, MIN_LENGTH_FOR_CORNERS_VISIBILITY, MIN_LENGTH_FOR_MIDPOINT_VISIBILITY,
+	MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, SELECTION_DRAG_ANGLE,
 };
 use crate::messages::frontend::utility_types::MouseCursorIcon;
 use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
@@ -379,7 +379,10 @@ impl BoundingBoxManager {
 		// Draw the bounding box rectangle
 		overlay_context.quad(quad, None);
 
-		let mut draw_handle = |point: DVec2| overlay_context.square(point, Some(6.), None, None);
+		let mut draw_handle = |point: DVec2| {
+			let quad = DAffine2::from_angle_translation((quad.top_left() - quad.top_right()).to_angle(), point) * Quad::from_box([DVec2::splat(-3.), DVec2::splat(3.)]);
+			overlay_context.quad(quad, Some(COLOR_OVERLAY_WHITE));
+		};
 
 		// Draw the horizontal midpoint drag handles
 		if matches!(category, HandleDisplayCategory::Full | HandleDisplayCategory::Narrow | HandleDisplayCategory::ReducedLandscape) {
diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs
index 3c95b59f02..a2befd59c1 100644
--- a/editor/src/messages/tool/tool_messages/select_tool.rs
+++ b/editor/src/messages/tool/tool_messages/select_tool.rs
@@ -1,7 +1,7 @@
 #![allow(clippy::too_many_arguments)]
 
 use super::tool_prelude::*;
-use crate::consts::{DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, ROTATE_INCREMENT, SELECTION_TOLERANCE};
+use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, ROTATE_INCREMENT, SELECTION_DRAG_ANGLE, SELECTION_TOLERANCE};
 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;
@@ -11,6 +11,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo
 use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes;
 use crate::messages::portfolio::document::utility_types::transformation::Selected;
 use crate::messages::preferences::SelectionMode;
+use crate::messages::tool::common_functionality::compass_rose::{Axis, CompassRose};
 use crate::messages::tool::common_functionality::graph_modification_utils::{get_text, is_layer_fed_by_node_of_name};
 use crate::messages::tool::common_functionality::pivot::Pivot;
 use crate::messages::tool::common_functionality::shape_editor::SelectionShapeType;
@@ -274,7 +275,7 @@ impl ToolTransition for SelectTool {
 enum SelectToolFsmState {
 	Ready { selection: NestedSelectionBehavior },
 	Drawing { selection_shape: SelectionShapeType },
-	Dragging,
+	Dragging { axis: Axis, using_compass: bool },
 	ResizingBounds,
 	SkewingBounds,
 	RotatingBounds,
@@ -298,11 +299,13 @@ struct SelectToolData {
 	layer_selected_on_start: Option<LayerNodeIdentifier>,
 	select_single_layer: Option<LayerNodeIdentifier>,
 	has_dragged: bool,
+	axis_align: bool,
 	non_duplicated_layers: Option<Vec<LayerNodeIdentifier>>,
 	bounding_box_manager: Option<BoundingBoxManager>,
 	snap_manager: SnapManager,
 	cursor: MouseCursorIcon,
 	pivot: Pivot,
+	compass_rose: CompassRose,
 	nested_selection_behavior: NestedSelectionBehavior,
 	selected_layers_count: usize,
 	selected_layers_changed: bool,
@@ -543,8 +546,106 @@ impl Fsm for SelectToolFsmState {
 					tool_data.bounding_box_manager.take();
 				}
 
+				let angle = bounds
+					.map(|bounds| transform * Quad::from_box(bounds))
+					.map_or(0., |quad| (quad.top_left() - quad.top_right()).to_angle());
+
+				let mouse_position = input.mouse.position;
+				let compass_rose_state = tool_data.compass_rose.compass_rose_state(mouse_position, angle);
+
+				let show_hover_ring = if let SelectToolFsmState::Dragging { axis, using_compass } = self {
+					using_compass && !axis.is_constraint()
+				} else {
+					compass_rose_state.is_ring()
+				};
+
+				let dragging_bounds = tool_data
+					.bounding_box_manager
+					.as_mut()
+					.and_then(|bounding_box| bounding_box.check_selected_edges(input.mouse.position))
+					.is_some();
+
+				let rotating_bounds = tool_data
+					.bounding_box_manager
+					.as_ref()
+					.map(|bounding_box| bounding_box.check_rotate(input.mouse.position))
+					.unwrap_or_default();
+
+				let might_resize_or_rotate = dragging_bounds || rotating_bounds;
+				let is_resizing_or_rotating = matches!(self, SelectToolFsmState::ResizingBounds { .. } | SelectToolFsmState::SkewingBounds | SelectToolFsmState::RotatingBounds);
+				let can_get_into_other_states = might_resize_or_rotate && !matches!(self, SelectToolFsmState::Dragging { .. });
+
+				let show_compass = !(can_get_into_other_states || is_resizing_or_rotating);
+				let show_compass_with_ring = bounds.map(|bounds| transform * Quad::from_box(bounds)).and_then(|quad| {
+					show_compass
+						.then_some(
+							matches!(self, SelectToolFsmState::Dragging { .. })
+								.then_some(show_hover_ring)
+								.or(quad.contains(mouse_position).then_some(show_hover_ring)),
+						)
+						.flatten()
+				});
+
 				// Update pivot
-				tool_data.pivot.update_pivot(document, &mut overlay_context);
+				tool_data.pivot.update_pivot(document, &mut overlay_context, angle);
+
+				// Update compass rose
+				tool_data.compass_rose.refresh_transform(document);
+				let compass_center = tool_data.compass_rose.compass_rose_position();
+				overlay_context.compass_rose(compass_center, angle, show_compass_with_ring);
+
+				let axis_state = if let SelectToolFsmState::Dragging { axis, .. } = self {
+					Some((axis, false))
+				} else {
+					compass_rose_state.axis_type().and_then(|axis| axis.is_constraint().then_some((axis, true)))
+				};
+
+				if let Some((axis, hover)) = axis_state {
+					if axis.is_constraint() {
+						let e0 = tool_data
+							.bounding_box_manager
+							.as_ref()
+							.map(|man| man.transform * Quad::from_box(man.bounds))
+							.map_or(DVec2::X, |quad| (quad.top_left() - quad.top_right()).normalize_or(DVec2::X));
+
+						let (direction, color) = match axis {
+							Axis::X => (e0, COLOR_OVERLAY_RED),
+							Axis::Y => (e0.perp(), COLOR_OVERLAY_GREEN),
+							_ => unreachable!(),
+						};
+
+						let viewport_diagonal = input.viewport_bounds.size().length();
+
+						let color = if !hover {
+							color
+						} else {
+							let color_string = &graphene_std::Color::from_rgb_str(color.strip_prefix('#').unwrap()).unwrap().with_alpha(0.25).rgba_hex();
+							&format!("#{}", color_string)
+						};
+						overlay_context.line(compass_center - direction * viewport_diagonal, compass_center + direction * viewport_diagonal, Some(color));
+					}
+				}
+
+				if axis_state.is_none_or(|(axis, _)| !axis.is_constraint()) && tool_data.axis_align {
+					let mouse_position = mouse_position - tool_data.drag_start;
+					let snap_resolution = SELECTION_DRAG_ANGLE.to_radians();
+					let angle = -mouse_position.angle_to(DVec2::X);
+					let snapped_angle = (angle / snap_resolution).round() * snap_resolution;
+
+					let mut other = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()).unwrap().with_alpha(0.25).rgba_hex();
+					other.insert(0, '#');
+					let other = other.as_str();
+
+					let extension = tool_data.drag_current - tool_data.drag_start;
+					let origin = compass_center - extension;
+					let viewport_diagonal = input.viewport_bounds.size().length();
+
+					let edge = DVec2::from_angle(snapped_angle) * viewport_diagonal;
+					let perp = edge.perp();
+
+					overlay_context.line(origin - edge * viewport_diagonal, origin + edge * viewport_diagonal, Some(COLOR_OVERLAY_BLUE));
+					overlay_context.line(origin - perp * viewport_diagonal, origin + perp * viewport_diagonal, Some(other));
+				}
 
 				// Check if the tool is in selection mode
 				if let Self::Drawing { selection_shape } = self {
@@ -676,9 +777,18 @@ impl Fsm for SelectToolFsmState {
 				// If the user clicks on new shape, make that layer their new selection.
 				// Otherwise enter the box select mode
 
+				let angle = tool_data
+					.bounding_box_manager
+					.as_ref()
+					.map(|man| man.transform * Quad::from_box(man.bounds))
+					.map_or(0., |quad| (quad.top_left() - quad.top_right()).to_angle());
+				let mouse_position = input.mouse.position;
+				let compass_rose_state = tool_data.compass_rose.compass_rose_state(mouse_position, angle);
+				let is_over_pivot = tool_data.pivot.is_over(mouse_position);
+
 				let state =
 				// Dragging the pivot
-				if tool_data.pivot.is_over(input.mouse.position) {
+				if is_over_pivot {
 					responses.add(DocumentMessage::StartTransaction);
 
 					// tool_data.snap_manager.start_snap(document, input, document.bounding_boxes(), true, true);
@@ -687,7 +797,7 @@ impl Fsm for SelectToolFsmState {
 					SelectToolFsmState::DraggingPivot
 				}
 				// Dragging one (or two, forming a corner) of the transform cage bounding box edges
-				else if let Some(_selected_edges) = dragging_bounds {
+				else if dragging_bounds.is_some() {
 					responses.add(DocumentMessage::StartTransaction);
 
 					tool_data.layers_dragging = selected;
@@ -756,7 +866,7 @@ impl Fsm for SelectToolFsmState {
 					SelectToolFsmState::RotatingBounds
 				}
 				// Dragging the selected layers around to transform them
-				else if intersection.is_some_and(|intersection| selected.iter().any(|selected_layer| intersection.starts_with(*selected_layer, document.metadata()))) {
+				else if compass_rose_state.can_grab() || intersection.is_some_and(|intersection| selected.iter().any(|selected_layer| intersection.starts_with(*selected_layer, document.metadata()))) {
 					responses.add(DocumentMessage::StartTransaction);
 
 					if input.keyboard.key(select_deepest) || tool_data.nested_selection_behavior == NestedSelectionBehavior::Deepest {
@@ -768,8 +878,11 @@ impl Fsm for SelectToolFsmState {
 					tool_data.layers_dragging = selected;
 
 					tool_data.get_snap_candidates(document, input);
-
-					SelectToolFsmState::Dragging
+					let axis = compass_rose_state.axis_type();
+					match axis {
+						Some(axis) => SelectToolFsmState::Dragging { axis, using_compass: true },
+						None => SelectToolFsmState::Dragging { axis: Axis::None, using_compass: false }
+					}
 				}
 				// Dragging a selection box
 				else {
@@ -790,7 +903,7 @@ impl Fsm for SelectToolFsmState {
 						tool_data.get_snap_candidates(document, input);
 
 						responses.add(DocumentMessage::StartTransaction);
-						SelectToolFsmState::Dragging
+						SelectToolFsmState::Dragging { axis: Axis::None, using_compass: false }
 					} else {
 						let selection_shape = if input.keyboard.key(lasso_select) { SelectionShapeType::Lasso } else { SelectionShapeType::Box };
 						SelectToolFsmState::Drawing { selection_shape }
@@ -806,7 +919,7 @@ impl Fsm for SelectToolFsmState {
 				let selection = tool_data.nested_selection_behavior;
 				SelectToolFsmState::Ready { selection }
 			}
-			(SelectToolFsmState::Dragging, SelectToolMessage::PointerMove(modifier_keys)) => {
+			(SelectToolFsmState::Dragging { axis, using_compass }, SelectToolMessage::PointerMove(modifier_keys)) => {
 				tool_data.has_dragged = true;
 
 				if input.keyboard.key(modifier_keys.duplicate) && tool_data.non_duplicated_layers.is_none() {
@@ -815,7 +928,7 @@ impl Fsm for SelectToolFsmState {
 					tool_data.stop_duplicates(document, responses);
 				}
 
-				let axis_align = input.keyboard.key(modifier_keys.axis_align);
+				tool_data.axis_align = input.keyboard.key(modifier_keys.axis_align) && !axis.is_constraint();
 
 				// Ignore the non duplicated layers if the current layers have not spawned yet.
 				let layers_exist = tool_data.layers_dragging.iter().all(|&layer| document.metadata().click_targets(layer).is_some());
@@ -823,7 +936,17 @@ impl Fsm for SelectToolFsmState {
 
 				let snap_data = SnapData::ignore(document, input, ignore);
 				let (start, current) = (tool_data.drag_start, tool_data.drag_current);
-				let mouse_delta = snap_drag(start, current, axis_align, snap_data, &mut tool_data.snap_manager, &tool_data.snap_candidates);
+				let mouse_delta = snap_drag(start, current, tool_data.axis_align, snap_data, &mut tool_data.snap_manager, &tool_data.snap_candidates);
+				let e0 = tool_data
+					.bounding_box_manager
+					.as_ref()
+					.map(|man| man.transform * Quad::from_box(man.bounds))
+					.map_or(DVec2::X, |quad| (quad.top_left() - quad.top_right()).normalize_or(DVec2::X));
+				let mouse_delta = match axis {
+					Axis::X => mouse_delta.project_onto(e0),
+					Axis::Y => mouse_delta.project_onto(e0.perp()),
+					Axis::None => mouse_delta,
+				};
 
 				// TODO: Cache the result of `shallowest_unique_layers` to avoid this heavy computation every frame of movement, see https://github.com/GraphiteEditor/Graphite/pull/481
 				for layer in document.network_interface.shallowest_unique_layers(&[]) {
@@ -843,7 +966,7 @@ impl Fsm for SelectToolFsmState {
 				];
 				tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses);
 
-				SelectToolFsmState::Dragging
+				SelectToolFsmState::Dragging { axis, using_compass }
 			}
 			(SelectToolFsmState::ResizingBounds, SelectToolMessage::PointerMove(modifier_keys)) => {
 				if let Some(ref mut bounds) = &mut tool_data.bounding_box_manager {
@@ -1017,14 +1140,14 @@ impl Fsm for SelectToolFsmState {
 				let selection = tool_data.nested_selection_behavior;
 				SelectToolFsmState::Ready { selection }
 			}
-			(SelectToolFsmState::Dragging, SelectToolMessage::PointerOutsideViewport(_)) => {
+			(SelectToolFsmState::Dragging { axis, using_compass }, SelectToolMessage::PointerOutsideViewport(_)) => {
 				// AutoPanning
 				if let Some(shift) = tool_data.auto_panning.shift_viewport(input, responses) {
 					tool_data.drag_current += shift;
 					tool_data.drag_start += shift;
 				}
 
-				SelectToolFsmState::Dragging
+				SelectToolFsmState::Dragging { axis, using_compass }
 			}
 			(SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds, SelectToolMessage::PointerOutsideViewport(_)) => {
 				// AutoPanning
@@ -1061,7 +1184,7 @@ impl Fsm for SelectToolFsmState {
 
 				state
 			}
-			(SelectToolFsmState::Dragging, SelectToolMessage::Enter) => {
+			(SelectToolFsmState::Dragging { .. }, SelectToolMessage::Enter) => {
 				let response = match input.mouse.position.distance(tool_data.drag_start) < 10. * f64::EPSILON {
 					true => DocumentMessage::AbortTransaction,
 					false => DocumentMessage::EndTransaction,
@@ -1072,9 +1195,10 @@ impl Fsm for SelectToolFsmState {
 				let selection = tool_data.nested_selection_behavior;
 				SelectToolFsmState::Ready { selection }
 			}
-			(SelectToolFsmState::Dragging, SelectToolMessage::DragStop { remove_from_selection }) => {
+			(SelectToolFsmState::Dragging { .. }, SelectToolMessage::DragStop { remove_from_selection }) => {
 				// Deselect layer if not snap dragging
 				responses.add(DocumentMessage::EndTransaction);
+				tool_data.axis_align = false;
 
 				if !tool_data.has_dragged && input.keyboard.key(remove_from_selection) && tool_data.layer_selected_on_start.is_none() {
 					// When you click on the layer with remove from selection key (shift) pressed, we deselect all nodes that are children.
@@ -1253,7 +1377,7 @@ impl Fsm for SelectToolFsmState {
 				let selection = tool_data.nested_selection_behavior;
 				SelectToolFsmState::Ready { selection }
 			}
-			(SelectToolFsmState::Dragging, SelectToolMessage::Abort) => {
+			(SelectToolFsmState::Dragging { .. }, SelectToolMessage::Abort) => {
 				responses.add(DocumentMessage::AbortTransaction);
 				tool_data.snap_manager.cleanup(responses);
 				responses.add(OverlaysMessage::Draw);
@@ -1336,15 +1460,19 @@ impl Fsm for SelectToolFsmState {
 				]);
 				responses.add(FrontendMessage::UpdateInputHints { hint_data });
 			}
-			SelectToolFsmState::Dragging if tool_data.has_dragged => {
-				let hint_data = HintData(vec![
+			SelectToolFsmState::Dragging { axis, using_compass } if tool_data.has_dragged => {
+				let mut hint_data = vec![
 					HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
-					HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain to Axis")]),
 					HintGroup(vec![
 						HintInfo::keys([Key::Alt], "Move Duplicate"),
 						HintInfo::keys([Key::Control, Key::KeyD], "Place Duplicate").add_mac_keys([Key::Command, Key::KeyD]),
 					]),
-				]);
+				];
+
+				if !(*using_compass && axis.is_constraint()) {
+					hint_data.push(HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain to Axis")]));
+				};
+				let hint_data = HintData(hint_data);
 				responses.add(FrontendMessage::UpdateInputHints { hint_data });
 			}
 			SelectToolFsmState::Drawing { .. } if tool_data.drag_start != tool_data.drag_current => {
@@ -1357,7 +1485,7 @@ impl Fsm for SelectToolFsmState {
 				]);
 				responses.add(FrontendMessage::UpdateInputHints { hint_data });
 			}
-			SelectToolFsmState::Drawing { .. } | SelectToolFsmState::Dragging => {}
+			SelectToolFsmState::Drawing { .. } | SelectToolFsmState::Dragging { .. } => {}
 			SelectToolFsmState::ResizingBounds => {
 				let hint_data = HintData(vec![
 					HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),


Test patch: