Add overlays for free-floating anchors on hovered/selected vector layers (#2630)

* Add selection overlay for free-floating anchors

* Add hover overlay for free-floating anchors

* Refactor outline_free_floating anchor

* Add single-anchor click targets on VectorData

* Modify ClickTarget to adapt for Subpath and PointGroup

* Fix Rust formatting

* Remove debug statements

* Add point groups support in VectorDataTable::add_upstream_click_targets

* Improve overlay for free floating anchors

* Remove datatype for nodes_to_shift

* Fix formatting in select_tool.rs

* Lints

* Code review

* Remove references to point_group

* Refactor ManipulatorGroup for FreePoint in ClickTargetGroup

* Rename ClickTargetGroup to ClickTargetType

* Refactor outline_free_floating_anchors into outline

* Adapt TransformCage to disable dragging and rotating on a single anchor layer

* Fix hover on single points

* Fix comments

* Lints

* Code review pass

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
seam0s
2025-06-19 18:50:53 -07:00
committed by Keavon Chambers
co-authored by Keavon Chambers
parent 477a3f6670
commit 878f5d3bf7
11 changed files with 288 additions and 93 deletions
@@ -108,7 +108,11 @@ impl SelectedLayerState {
}
pub fn selected_points_count(&self) -> usize {
self.selected_points.len()
let count = self.selected_points.iter().fold(0, |acc, point| {
let is_ignored = (point.as_handle().is_some() && self.ignore_handles) || (point.as_anchor().is_some() && self.ignore_anchors);
acc + if is_ignored { 0 } else { 1 }
});
count
}
}
@@ -449,7 +449,11 @@ impl SnapManager {
if let Some(ind) = &self.indicator {
for layer in &ind.outline_layers {
let &Some(layer) = layer else { continue };
overlay_context.outline(snap_data.document.metadata().layer_outline(layer), snap_data.document.metadata().transform_to_viewport(layer), None);
overlay_context.outline(
snap_data.document.metadata().layer_with_free_points_outline(layer),
snap_data.document.metadata().transform_to_viewport(layer),
None,
);
}
if let Some(quad) = ind.target_bounds {
overlay_context.quad(to_viewport * quad, None, None);
@@ -1,8 +1,8 @@
use super::snapping::{self, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnappedPoint};
use crate::consts::{
BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, COLOR_OVERLAY_WHITE, MAXIMUM_ALT_SCALE_FACTOR, MIN_LENGTH_FOR_CORNERS_VISIBILITY, MIN_LENGTH_FOR_EDGE_RESIZE_PRIORITY_OVER_CORNERS,
MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY, RESIZE_HANDLE_SIZE, SELECTION_DRAG_ANGLE, SKEW_TRIANGLE_OFFSET,
SKEW_TRIANGLE_SIZE,
BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, COLOR_OVERLAY_WHITE, MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT, MAXIMUM_ALT_SCALE_FACTOR, MIN_LENGTH_FOR_CORNERS_VISIBILITY,
MIN_LENGTH_FOR_EDGE_RESIZE_PRIORITY_OVER_CORNERS, MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY, RESIZE_HANDLE_SIZE,
SELECTION_DRAG_ANGLE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE,
};
use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
@@ -52,6 +52,8 @@ enum TransformCageSizeCategory {
Narrow,
/// - ![Diagram](https://files.keavon.com/-/OpenPaleturquoiseArthropods/capture.png)
Flat,
/// A single point in space with no width or height.
Point,
}
impl SelectedEdges {
@@ -635,7 +637,12 @@ impl BoundingBoxManager {
fn overlay_display_category(&self) -> TransformCageSizeCategory {
let quad = self.transform * Quad::from_box(self.bounds);
// Check if the area is essentially zero because either the width or height is smaller than an epsilon
// Check if the bounds are essentially the same because the width and height are smaller than MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT
if self.is_bounds_point() {
return TransformCageSizeCategory::Point;
}
// Check if the area is essentially zero because either the width or height is smaller than MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT
if self.is_bounds_flat() {
return TransformCageSizeCategory::Flat;
}
@@ -661,7 +668,12 @@ impl BoundingBoxManager {
/// Determine if these bounds are flat ([`TransformCageSizeCategory::Flat`]), which means that the width and/or height is essentially zero and the bounds are a line with effectively no area. This can happen on actual lines (axis-aligned, i.e. drawn horizontally or vertically) or when an element is scaled to zero in X or Y. A flat transform cage can still be rotated by a transformation, but its local space remains flat.
fn is_bounds_flat(&self) -> bool {
(self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(1e-4)).any()
(self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT)).any()
}
/// Determine if these bounds are point ([`TransformCageSizeCategory::Point`]), which means that the width and height are essentially zero and the bounds are a point with no area. This can happen on points when an element is scaled to zero in both X and Y, or if an element is just a single anchor point. A point transform cage cannot be rotated by a transformation, and its local space remains a point.
fn is_bounds_point(&self) -> bool {
(self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT)).all()
}
/// Determine if the given point in viewport space falls within the bounds of `self`.
@@ -699,7 +711,7 @@ impl BoundingBoxManager {
let [edge_min_x, edge_min_y] = self.compute_viewport_threshold(MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR);
let [midpoint_threshold_x, midpoint_threshold_y] = self.compute_viewport_threshold(MIN_LENGTH_FOR_EDGE_RESIZE_PRIORITY_OVER_CORNERS);
if min.x - cursor.x < threshold_x && min.y - cursor.y < threshold_y && cursor.x - max.x < threshold_x && cursor.y - max.y < threshold_y {
if (min.x - cursor.x < threshold_x && min.y - cursor.y < threshold_y) && (cursor.x - max.x < threshold_x && cursor.y - max.y < threshold_y) {
let mut top = (cursor.y - min.y).abs() < threshold_y;
let mut bottom = (max.y - cursor.y).abs() < threshold_y;
let mut left = (cursor.x - min.x).abs() < threshold_x;
@@ -741,11 +753,11 @@ impl BoundingBoxManager {
}
// On bounds with no width/height, disallow transformation in the relevant axis
if width < f64::EPSILON * 1000. {
if width < MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT {
left = false;
right = false;
}
if height < f64::EPSILON * 1000. {
if height < MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT {
top = false;
bottom = false;
}
@@ -767,9 +779,12 @@ impl BoundingBoxManager {
let [threshold_x, threshold_y] = self.compute_viewport_threshold(BOUNDS_ROTATE_THRESHOLD);
let cursor = self.transform.inverse().transform_point2(cursor);
let flat = (self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(1e-4)).any();
let flat = self.is_bounds_flat();
let point = self.is_bounds_point();
let within_square_bounds = |center: &DVec2| center.x - threshold_x < cursor.x && cursor.x < center.x + threshold_x && center.y - threshold_y < cursor.y && cursor.y < center.y + threshold_y;
if flat {
if point {
false
} else if flat {
[self.bounds[0], self.bounds[1]].iter().any(within_square_bounds)
} else {
self.evaluate_transform_handle_positions().iter().any(within_square_bounds)