Make the transform cage show/hide resize grips as space allows (#2209)

* Changes rotation handles to be around overlay squares

Fixes https://discord.com/channels/731730685944922173/931942323644928040/1330785941786329209

* Fix zero width objects not being selected by slightly nudging the transform

* Follow the categorical limits to render overlay quads

As discussed here: https://discord.com/channels/731730685944922173/931942323644928040/1331166336923074600

* Replace area based calculations with edge based calculations

* Fix 3rd category vis

* Code review

* Add missing powi(2)

* Fixes to handle logic

* Remove single axis prioritisation

* Explicitly check for distance to find nearest handle

* Replace threshold check based on corner vis bounds

* Fix discrepancy at h=12px

* Allow grab when box is too small by disabling resizing within bounds

* Replace inside resize pixel limit

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
mTvare
2025-01-30 01:10:40 +00:00
committed by GitHub
co-authored by Keavon Chambers
parent c5a3c32114
commit a0f8f89e71
10 changed files with 200 additions and 88 deletions
@@ -1296,7 +1296,7 @@ impl NodeNetworkInterface {
let clip_input = artboard.unwrap().inputs.get(5).unwrap();
if let NodeInput::Value { tagged_value, .. } = clip_input {
if tagged_value.to_primitive_string() == "true" {
return Some(Quad::constraint_bounds(
return Some(Quad::clip(
self.document_metadata.bounding_box_document(layer).unwrap_or_default(),
self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(),
));
@@ -5,7 +5,7 @@ mod layer_snapper;
mod snap_results;
pub use {alignment_snapper::*, distribution_snapper::*, grid_snapper::*, layer_snapper::*, snap_results::*};
use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_SNAP_BACKGROUND, COLOR_OVERLAY_WHITE};
use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_LABEL_BACKGROUND, COLOR_OVERLAY_WHITE};
use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, Pivot};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::{GridSnapTarget, PathSnapTarget, SnapTarget};
@@ -470,7 +470,7 @@ impl SnapManager {
if !any_align && ind.distribution_equal_distance_x.is_none() && ind.distribution_equal_distance_y.is_none() {
let text = format!("[{}] from [{}]", ind.target, ind.source);
let transform = DAffine2::from_translation(viewport - DVec2::new(0., 4.));
overlay_context.text(&text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_SNAP_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);
overlay_context.text(&text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_LABEL_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);
overlay_context.square(viewport, Some(4.), Some(COLOR_OVERLAY_BLUE), Some(COLOR_OVERLAY_BLUE));
}
}
@@ -1,4 +1,6 @@
use crate::consts::{BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, SELECTION_DRAG_ANGLE};
use crate::consts::{
BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, 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;
use crate::messages::portfolio::document::utility_types::transformation::OriginalTransforms;
@@ -30,6 +32,17 @@ pub struct SelectedEdges {
aspect_ratio: f64,
}
#[derive(Clone, Debug, Default, PartialEq)]
enum HandleDisplayCategory {
#[default]
Full,
ReducedLandscape,
ReducedPortrait,
ReducedBoth,
Narrow,
Flat,
}
impl SelectedEdges {
pub fn new(top: bool, bottom: bool, left: bool, right: bool, bounds: [DVec2; 2]) -> Self {
let size = (bounds[0] - bounds[1]).abs();
@@ -287,24 +300,78 @@ impl BoundingBoxManager {
let (left, top): (f64, f64) = self.bounds[0].into();
let (right, bottom): (f64, f64) = self.bounds[1].into();
[
self.transform.transform_point2(DVec2::new(left, top)),
self.transform.transform_point2(DVec2::new(left, (top + bottom) / 2.)),
self.transform.transform_point2(DVec2::new(left, bottom)),
self.transform.transform_point2(DVec2::new((left + right) / 2., top)),
self.transform.transform_point2(DVec2::new((left + right) / 2., bottom)),
self.transform.transform_point2(DVec2::new(right, top)),
self.transform.transform_point2(DVec2::new(right, (top + bottom) / 2.)),
self.transform.transform_point2(DVec2::new(right, bottom)),
DVec2::new(left, top),
DVec2::new(left, (top + bottom) / 2.),
DVec2::new(left, bottom),
DVec2::new((left + right) / 2., top),
DVec2::new((left + right) / 2., bottom),
DVec2::new(right, top),
DVec2::new(right, (top + bottom) / 2.),
DVec2::new(right, bottom),
]
}
/// Update the position of the bounding box and transform handles
pub fn render_overlays(&mut self, overlay_context: &mut OverlayContext) {
overlay_context.quad(self.transform * Quad::from_box(self.bounds), None);
let quad = self.transform * Quad::from_box(self.bounds);
let category = self.overlay_display_category(quad);
for position in self.evaluate_transform_handle_positions() {
overlay_context.square(position, Some(6.), None, None);
let horizontal_edges = [quad.top_right().midpoint(quad.bottom_right()), quad.bottom_left().midpoint(quad.top_left())];
let vertical_edges = [quad.top_left().midpoint(quad.top_right()), quad.bottom_right().midpoint(quad.bottom_left())];
// Draw the bounding box rectangle
overlay_context.quad(quad, None);
let mut draw_handle = |point: DVec2| overlay_context.square(point, Some(6.), None, None);
// Draw the horizontal midpoint drag handles
if matches!(category, HandleDisplayCategory::Full | HandleDisplayCategory::Narrow | HandleDisplayCategory::ReducedLandscape) {
horizontal_edges.map(&mut draw_handle);
}
// Draw the vertical midpoint drag handles
if matches!(category, HandleDisplayCategory::Full | HandleDisplayCategory::Narrow | HandleDisplayCategory::ReducedPortrait) {
vertical_edges.map(&mut draw_handle);
}
// Draw the corner drag handles
if matches!(
category,
HandleDisplayCategory::Full | HandleDisplayCategory::ReducedBoth | HandleDisplayCategory::ReducedLandscape | HandleDisplayCategory::ReducedPortrait
) {
quad.0.map(&mut draw_handle);
}
// Draw the flat line endpoint drag handles
if category == HandleDisplayCategory::Flat {
draw_handle(self.transform.transform_point2(self.bounds[0]));
draw_handle(self.transform.transform_point2(self.bounds[1]));
}
}
fn overlay_display_category(&self, quad: Quad) -> HandleDisplayCategory {
// Check if the area is essentially zero because either the width or height is smaller than an epsilon
if (self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(1e-4)).any() {
return HandleDisplayCategory::Flat;
}
let vertical_length = (quad.top_left() - quad.top_right()).length_squared();
let horizontal_length = (quad.bottom_left() - quad.top_left()).length_squared();
let corners_visible = vertical_length >= MIN_LENGTH_FOR_CORNERS_VISIBILITY.powi(2) && horizontal_length >= MIN_LENGTH_FOR_CORNERS_VISIBILITY.powi(2);
if corners_visible {
let vertical_edge_visible = vertical_length > MIN_LENGTH_FOR_MIDPOINT_VISIBILITY.powi(2);
let horizontal_edge_visible = horizontal_length > MIN_LENGTH_FOR_MIDPOINT_VISIBILITY.powi(2);
return match (vertical_edge_visible, horizontal_edge_visible) {
(true, true) => HandleDisplayCategory::Full,
(true, false) => HandleDisplayCategory::ReducedPortrait,
(false, true) => HandleDisplayCategory::ReducedLandscape,
(false, false) => HandleDisplayCategory::ReducedBoth,
};
}
HandleDisplayCategory::Narrow
}
/// Compute the threshold in viewport space. This only works with affine transforms as it assumes lines remain parallel.
@@ -313,18 +380,27 @@ impl BoundingBoxManager {
let viewport_x = self.transform.transform_vector2(DVec2::X).normalize_or_zero() * scalar;
let viewport_y = self.transform.transform_vector2(DVec2::Y).normalize_or_zero() * scalar;
let threshold_x = inverse.transform_vector2(viewport_x).length();
let threshold_y = inverse.transform_vector2(viewport_y).length();
[threshold_x, threshold_y]
}
/// Check if the user has selected the edge for dragging (returns which edge in order top, bottom, left, right)
/// Check if the user has selected the edge for dragging.
///
/// Returns which edge in the order:
///
/// `top, bottom, left, right`
pub fn check_selected_edges(&self, cursor: DVec2) -> Option<(bool, bool, bool, bool)> {
let cursor = self.transform.inverse().transform_point2(cursor);
let min = self.bounds[0].min(self.bounds[1]);
let max = self.bounds[0].max(self.bounds[1]);
let [threshold_x, threshold_y] = self.compute_viewport_threshold(BOUNDS_SELECT_THRESHOLD);
let [corner_min_x, corner_min_y] = self.compute_viewport_threshold(MIN_LENGTH_FOR_CORNERS_VISIBILITY);
let [edge_min_x, edge_min_y] = self.compute_viewport_threshold(MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR);
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;
@@ -332,24 +408,33 @@ impl BoundingBoxManager {
let mut left = (cursor.x - min.x).abs() < threshold_x;
let mut right = (max.x - cursor.x).abs() < threshold_x;
// Prioritise single axis transformations on very small bounds
if cursor.y - min.y + max.y - cursor.y < threshold_y * 2. && (left || right) {
top = false;
bottom = false;
}
if cursor.x - min.x + max.x - cursor.x < threshold_x * 2. && (top || bottom) {
left = false;
right = false;
}
let width = max.x - min.x;
let height = max.y - min.y;
// On bounds with no width/height, disallow transformation in the relevant axis
if (max.x - min.x) < f64::EPSILON * 1000. {
left = false;
right = false;
}
if (max.y - min.y) < f64::EPSILON * 1000. {
top = false;
bottom = false;
if width < edge_min_x || height <= edge_min_y {
if min.x < cursor.x && cursor.x < max.x && cursor.y < max.y && cursor.y > min.y {
return None;
}
// Prioritize single axis transformations on very small bounds
if height < corner_min_y && (left || right) {
top = false;
bottom = false;
}
if width < corner_min_x && (top || bottom) {
left = false;
right = false;
}
// On bounds with no width/height, disallow transformation in the relevant axis
if width < f64::EPSILON * 1000. {
left = false;
right = false;
}
if height < f64::EPSILON * 1000. {
top = false;
bottom = false;
}
}
if top || bottom || left || right {
@@ -365,19 +450,19 @@ impl BoundingBoxManager {
let cursor = self.transform.inverse().transform_point2(cursor);
let [threshold_x, threshold_y] = self.compute_viewport_threshold(BOUNDS_ROTATE_THRESHOLD);
let min = self.bounds[0].min(self.bounds[1]);
let max = self.bounds[0].max(self.bounds[1]);
let outside_bounds = (min.x > cursor.x || cursor.x > max.x) || (min.y > cursor.y || cursor.y > max.y);
let inside_extended_bounds = min.x - cursor.x < threshold_x && min.y - cursor.y < threshold_y && cursor.x - max.x < threshold_x && cursor.y - max.y < threshold_y;
outside_bounds & inside_extended_bounds
let narrow = (self.bounds[0] - self.bounds[1]).abs().cmple(DVec2::splat(1e-4)).any();
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 narrow {
[self.bounds[0], self.bounds[1]].iter().any(within_square_bounds)
} else {
self.evaluate_transform_handle_positions().iter().any(within_square_bounds)
}
}
/// Gets the required mouse cursor to show resizing bounds or optionally rotation
pub fn get_cursor(&self, input: &InputPreprocessorMessageHandler, rotate: bool) -> MouseCursorIcon {
if let Some(directions) = self.check_selected_edges(input.mouse.position) {
match directions {
if let Some((top, bottom, left, right)) = self.check_selected_edges(input.mouse.position) {
match (top, bottom, left, right) {
(true, _, false, false) | (_, true, false, false) => MouseCursorIcon::NSResize,
(false, false, true, _) | (false, false, _, true) => MouseCursorIcon::EWResize,
(true, _, true, _) | (_, true, _, true) => MouseCursorIcon::NWSEResize,
@@ -22,6 +22,7 @@ use graphene_core::text::load_face;
use graphene_std::renderer::Rect;
use graphene_std::vector::misc::BooleanOperation;
use glam::DMat2;
use std::fmt;
#[derive(Default)]
@@ -456,10 +457,13 @@ impl Fsm for SelectToolFsmState {
.selected_visible_and_unlocked_layers(&document.network_interface)
.find(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[]))
.map(|layer| document.metadata().transform_to_viewport(layer));
let transform = transform.unwrap_or(DAffine2::IDENTITY);
// Check if the matrix is not invertible
let mut transform = transform.unwrap_or(DAffine2::IDENTITY);
if transform.matrix2.determinant() == 0. {
return self;
transform.matrix2 += DMat2::IDENTITY * 1e-4; // TODO: Is this the cleanest way to handle this?
}
let bounds = document
.network_interface
.selected_nodes(&[])
@@ -1,4 +1,4 @@
use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_SNAP_BACKGROUND, COLOR_OVERLAY_WHITE, SLOWING_DIVISOR};
use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_LABEL_BACKGROUND, COLOR_OVERLAY_WHITE, SLOWING_DIVISOR};
use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
use crate::messages::portfolio::document::overlays::utility_types::{OverlayProvider, Pivot};
use crate::messages::portfolio::document::utility_types::transformation::{Axis, OriginalTransforms, Selected, TransformOperation, Typing};
@@ -477,7 +477,7 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
}
}
overlay_context.text(&grs_value_text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_SNAP_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);
overlay_context.text(&grs_value_text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_LABEL_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);
}
}
TransformLayerMessage::PointerMove { slow_key, snap_key } => {