513 lines
18 KiB
Rust
513 lines
18 KiB
Rust
//! Pure vector-selection and transform calculations for the Iced GUI.
|
|
//!
|
|
//! Purpose: Centralize overlap cycling, screen-constant rotated handle hit testing, and safe
|
|
//! move/resize/rotate calculations. Logic & Workflow: Drag calculations always start from an
|
|
//! immutable shape snapshot, convert pointer movement into the shape's local axes, apply optional
|
|
//! aspect/center constraints, and reject non-finite or inverted bounds. Side Effects / Dependencies:
|
|
//! This module has no I/O or engine side effects; callers commit returned shapes through
|
|
//! `hcie-engine-api`.
|
|
|
|
use hcie_engine_api::{VectorEditHandle, VectorShape};
|
|
|
|
/// Smallest permitted vector dimension in canvas pixels.
|
|
pub const MIN_VECTOR_SIZE: f32 = 1.0;
|
|
/// Resize handle hit-box half extent in screen pixels.
|
|
pub const HANDLE_HIT_HALF_PX: f32 = 7.0;
|
|
/// Rotation handle distance above the shape in screen pixels.
|
|
pub const ROTATE_OFFSET_PX: f32 = 24.0;
|
|
/// Rotation handle hit radius in screen pixels.
|
|
pub const ROTATE_HIT_RADIUS_PX: f32 = 7.0;
|
|
|
|
/// Immutable edit-session snapshot tied to the layer from which it was captured.
|
|
///
|
|
/// Purpose: Prevents Cancel from restoring shapes into a different active layer. Logic & Workflow:
|
|
/// the app captures this once on selection and restores `shapes` by `layer_id`. Side Effects: None.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct VectorEditSnapshot {
|
|
pub layer_id: u64,
|
|
pub shapes: Vec<VectorShape>,
|
|
}
|
|
|
|
/// Immutable basis for one pointer drag.
|
|
///
|
|
/// Purpose: Makes every move event deterministic and prevents cumulative floating-point drift.
|
|
/// Logic & Workflow: `transform_shape` derives the current shape from `original` and the total
|
|
/// pointer displacement since `pointer_start`. Side Effects: None.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct VectorDragSession {
|
|
pub layer_id: u64,
|
|
pub shape_index: usize,
|
|
pub handle: VectorEditHandle,
|
|
pub pointer_start: (f32, f32),
|
|
pub original: VectorShape,
|
|
}
|
|
|
|
/// Chooses the next selected index for a point containing `hits` in bottom-to-top order.
|
|
///
|
|
/// Arguments: `hits` contains shape indices under the pointer and `current` is the prior selection.
|
|
/// Returns: The topmost hit initially, then the next lower overlapping shape on repeated clicks.
|
|
/// Side Effects: None.
|
|
pub fn cycle_selection(hits: &[usize], current: Option<usize>) -> Option<usize> {
|
|
if hits.is_empty() {
|
|
return None;
|
|
}
|
|
match current.and_then(|index| hits.iter().position(|hit| *hit == index)) {
|
|
Some(position) => Some(hits[(position + hits.len() - 1) % hits.len()]),
|
|
None => hits.last().copied(),
|
|
}
|
|
}
|
|
|
|
/// Hit-tests rotated vector handles using screen-constant target sizes.
|
|
///
|
|
/// Arguments: `bounds` are normalized canvas bounds, `angle` is radians, `point` is canvas-space,
|
|
/// and `zoom` maps canvas pixels to screen pixels. Returns: The matching handle, preferring rotate
|
|
/// and resize targets over the move interior. Side Effects: None.
|
|
pub fn hit_test_handle(
|
|
bounds: (f32, f32, f32, f32),
|
|
angle: f32,
|
|
point: (f32, f32),
|
|
zoom: f32,
|
|
) -> VectorEditHandle {
|
|
use VectorEditHandle as H;
|
|
|
|
if !finite4(bounds) || !angle.is_finite() || !point.0.is_finite() || !point.1.is_finite() {
|
|
return H::None;
|
|
}
|
|
let (left, top, right, bottom) = bounds;
|
|
if right - left < MIN_VECTOR_SIZE || bottom - top < MIN_VECTOR_SIZE {
|
|
return H::None;
|
|
}
|
|
let zoom = zoom.max(0.000_1);
|
|
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
|
|
let local = inverse_rotate(point, center, angle);
|
|
let rotate_offset = ROTATE_OFFSET_PX / zoom;
|
|
let rotate_radius = ROTATE_HIT_RADIUS_PX / zoom;
|
|
let rotate_delta = (local.0 - center.0, local.1 - (top - rotate_offset));
|
|
if rotate_delta.0 * rotate_delta.0 + rotate_delta.1 * rotate_delta.1
|
|
<= rotate_radius * rotate_radius
|
|
{
|
|
return H::Rotate;
|
|
}
|
|
|
|
let half = HANDLE_HIT_HALF_PX / zoom;
|
|
let points = [
|
|
(H::TopLeft, left, top),
|
|
(H::Top, center.0, top),
|
|
(H::TopRight, right, top),
|
|
(H::Left, left, center.1),
|
|
(H::Right, right, center.1),
|
|
(H::BottomLeft, left, bottom),
|
|
(H::Bottom, center.0, bottom),
|
|
(H::BottomRight, right, bottom),
|
|
];
|
|
for (handle, x, y) in points {
|
|
if (local.0 - x).abs() <= half && (local.1 - y).abs() <= half {
|
|
return handle;
|
|
}
|
|
}
|
|
if local.0 >= left && local.0 <= right && local.1 >= top && local.1 <= bottom {
|
|
H::Move
|
|
} else {
|
|
H::None
|
|
}
|
|
}
|
|
|
|
/// Derives a safe shape for the current pointer position in a drag.
|
|
///
|
|
/// Arguments: `session` is the immutable drag basis, `pointer` is canvas-space,
|
|
/// `lock_aspect` corresponds to Shift (aspect-lock on resize), `from_center`
|
|
/// corresponds to Alt, and `snap_angle` corresponds to Shift on rotation.
|
|
/// Returns: A finite shape with dimensions at least `MIN_VECTOR_SIZE`; invalid
|
|
/// pointer input returns the original shape.
|
|
/// Side Effects: None.
|
|
pub fn transform_shape(
|
|
session: &VectorDragSession,
|
|
pointer: (f32, f32),
|
|
lock_aspect: bool,
|
|
from_center: bool,
|
|
snap_angle: bool,
|
|
) -> VectorShape {
|
|
use VectorEditHandle as H;
|
|
|
|
if !pointer.0.is_finite() || !pointer.1.is_finite() {
|
|
return session.original.clone();
|
|
}
|
|
let mut result = session.original.clone();
|
|
let delta = (
|
|
pointer.0 - session.pointer_start.0,
|
|
pointer.1 - session.pointer_start.1,
|
|
);
|
|
if !delta.0.is_finite() || !delta.1.is_finite() {
|
|
return result;
|
|
}
|
|
if session.handle == H::Move {
|
|
result.apply_handle(H::Move, delta.0, delta.1, None);
|
|
return finite_shape_or_original(result, &session.original);
|
|
}
|
|
|
|
let bounds = session.original.normalized_bounds();
|
|
let center = ((bounds.0 + bounds.2) * 0.5, (bounds.1 + bounds.3) * 0.5);
|
|
if session.handle == H::Rotate {
|
|
if matches!(session.original, VectorShape::FreePath { .. }) {
|
|
return result;
|
|
}
|
|
let start_angle =
|
|
(session.pointer_start.1 - center.1).atan2(session.pointer_start.0 - center.0);
|
|
let current_angle = (pointer.1 - center.1).atan2(pointer.0 - center.0);
|
|
let angle = session.original.angle() + normalize_angle(current_angle - start_angle);
|
|
let angle = if snap_angle {
|
|
snap_to_15deg(angle)
|
|
} else {
|
|
normalize_angle(angle)
|
|
};
|
|
if angle.is_finite() {
|
|
result.set_angle(angle);
|
|
}
|
|
return result;
|
|
}
|
|
if session.handle == H::None {
|
|
return result;
|
|
}
|
|
|
|
let local_delta = inverse_rotate_delta(delta, session.original.angle());
|
|
let resized = resize_bounds(
|
|
bounds,
|
|
session.handle,
|
|
local_delta,
|
|
lock_aspect,
|
|
from_center,
|
|
);
|
|
result.set_bounds(resized.0, resized.1, resized.2, resized.3);
|
|
finite_shape_or_original(result, &session.original)
|
|
}
|
|
|
|
/// Calculates normalized, minimum-sized resize bounds.
|
|
///
|
|
/// Arguments: Original normalized `bounds`, active resize `handle`, pointer `delta` in local shape
|
|
/// axes, and Shift/Alt constraints. Returns: Normalized finite bounds without edge inversion.
|
|
/// Side Effects: None.
|
|
pub fn resize_bounds(
|
|
bounds: (f32, f32, f32, f32),
|
|
handle: VectorEditHandle,
|
|
delta: (f32, f32),
|
|
lock_aspect: bool,
|
|
from_center: bool,
|
|
) -> (f32, f32, f32, f32) {
|
|
use VectorEditHandle as H;
|
|
|
|
if !finite4(bounds) || !delta.0.is_finite() || !delta.1.is_finite() {
|
|
return bounds;
|
|
}
|
|
let (left, top, right, bottom) = bounds;
|
|
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
|
|
let affects_left = matches!(handle, H::TopLeft | H::Left | H::BottomLeft);
|
|
let affects_right = matches!(handle, H::TopRight | H::Right | H::BottomRight);
|
|
let affects_top = matches!(handle, H::TopLeft | H::Top | H::TopRight);
|
|
let affects_bottom = matches!(handle, H::BottomLeft | H::Bottom | H::BottomRight);
|
|
if !(affects_left || affects_right || affects_top || affects_bottom) {
|
|
return bounds;
|
|
}
|
|
|
|
let mut new_left = left;
|
|
let mut new_right = right;
|
|
let mut new_top = top;
|
|
let mut new_bottom = bottom;
|
|
let scale = if from_center { 2.0 } else { 1.0 };
|
|
if affects_left {
|
|
new_left = (left + delta.0 * scale).min(right - MIN_VECTOR_SIZE);
|
|
if from_center {
|
|
new_right = 2.0 * center.0 - new_left;
|
|
}
|
|
}
|
|
if affects_right {
|
|
new_right = (right + delta.0 * scale).max(left + MIN_VECTOR_SIZE);
|
|
if from_center {
|
|
new_left = 2.0 * center.0 - new_right;
|
|
}
|
|
}
|
|
if affects_top {
|
|
new_top = (top + delta.1 * scale).min(bottom - MIN_VECTOR_SIZE);
|
|
if from_center {
|
|
new_bottom = 2.0 * center.1 - new_top;
|
|
}
|
|
}
|
|
if affects_bottom {
|
|
new_bottom = (bottom + delta.1 * scale).max(top + MIN_VECTOR_SIZE);
|
|
if from_center {
|
|
new_top = 2.0 * center.1 - new_bottom;
|
|
}
|
|
}
|
|
|
|
if lock_aspect {
|
|
let ratio = ((right - left) / (bottom - top).max(MIN_VECTOR_SIZE)).max(0.000_1);
|
|
let mut width = (new_right - new_left).max(MIN_VECTOR_SIZE);
|
|
let mut height = (new_bottom - new_top).max(MIN_VECTOR_SIZE);
|
|
if affects_left || affects_right {
|
|
height = (width / ratio).max(MIN_VECTOR_SIZE);
|
|
} else {
|
|
width = (height * ratio).max(MIN_VECTOR_SIZE);
|
|
}
|
|
let anchor_x = if from_center {
|
|
center.0
|
|
} else if affects_left {
|
|
right
|
|
} else if affects_right {
|
|
left
|
|
} else {
|
|
center.0
|
|
};
|
|
let anchor_y = if from_center {
|
|
center.1
|
|
} else if affects_top {
|
|
bottom
|
|
} else if affects_bottom {
|
|
top
|
|
} else {
|
|
center.1
|
|
};
|
|
if from_center || !(affects_left || affects_right) {
|
|
new_left = anchor_x - width * 0.5;
|
|
new_right = anchor_x + width * 0.5;
|
|
} else if affects_left {
|
|
new_left = anchor_x - width;
|
|
new_right = anchor_x;
|
|
} else {
|
|
new_left = anchor_x;
|
|
new_right = anchor_x + width;
|
|
}
|
|
if from_center || !(affects_top || affects_bottom) {
|
|
new_top = anchor_y - height * 0.5;
|
|
new_bottom = anchor_y + height * 0.5;
|
|
} else if affects_top {
|
|
new_top = anchor_y - height;
|
|
new_bottom = anchor_y;
|
|
} else {
|
|
new_top = anchor_y;
|
|
new_bottom = anchor_y + height;
|
|
}
|
|
}
|
|
|
|
let result = (new_left, new_top, new_right, new_bottom);
|
|
if finite4(result)
|
|
&& result.2 - result.0 >= MIN_VECTOR_SIZE
|
|
&& result.3 - result.1 >= MIN_VECTOR_SIZE
|
|
{
|
|
result
|
|
} else {
|
|
bounds
|
|
}
|
|
}
|
|
|
|
/// Normalizes an angle to `[-PI, PI]` while preserving finite values.
|
|
///
|
|
/// Arguments: `angle` in radians. Returns: A wrapped angle, or zero for non-finite input.
|
|
/// Side Effects: None.
|
|
pub fn normalize_angle(angle: f32) -> f32 {
|
|
if !angle.is_finite() {
|
|
return 0.0;
|
|
}
|
|
let two_pi = std::f32::consts::TAU;
|
|
(angle + std::f32::consts::PI).rem_euclid(two_pi) - std::f32::consts::PI
|
|
}
|
|
|
|
/// Snaps an angle (radians) to the nearest 15° increment.
|
|
///
|
|
/// Used for Shift-constrained drawing and rotation. 15° = PI/12.
|
|
/// Returns the nearest snapped angle within [-PI, PI].
|
|
pub fn snap_to_15deg(angle: f32) -> f32 {
|
|
let step = std::f32::consts::PI / 12.0; // 15°
|
|
let snapped = (angle / step).round() * step;
|
|
normalize_angle(snapped)
|
|
}
|
|
|
|
/// Inverse-rotates a point around a center. Arguments and return are canvas-space coordinates.
|
|
/// Side Effects: None.
|
|
fn inverse_rotate(point: (f32, f32), center: (f32, f32), angle: f32) -> (f32, f32) {
|
|
let delta = (point.0 - center.0, point.1 - center.1);
|
|
let local = inverse_rotate_delta(delta, angle);
|
|
(center.0 + local.0, center.1 + local.1)
|
|
}
|
|
|
|
/// Inverse-rotates a displacement by `angle`. Returns the shape-local displacement.
|
|
/// Side Effects: None.
|
|
fn inverse_rotate_delta(delta: (f32, f32), angle: f32) -> (f32, f32) {
|
|
let (sin, cos) = angle.sin_cos();
|
|
(
|
|
delta.0 * cos + delta.1 * sin,
|
|
-delta.0 * sin + delta.1 * cos,
|
|
)
|
|
}
|
|
|
|
/// Checks four floats for finiteness. Returns true only when all values are finite.
|
|
/// Side Effects: None.
|
|
fn finite4(values: (f32, f32, f32, f32)) -> bool {
|
|
values.0.is_finite() && values.1.is_finite() && values.2.is_finite() && values.3.is_finite()
|
|
}
|
|
|
|
/// Rejects shapes whose resulting bounds contain NaN/infinity or violate minimum size.
|
|
/// Returns: `candidate` when valid, otherwise `original`. Side Effects: None.
|
|
fn finite_shape_or_original(candidate: VectorShape, original: &VectorShape) -> VectorShape {
|
|
let bounds = candidate.normalized_bounds();
|
|
if finite4(bounds)
|
|
&& bounds.2 - bounds.0 >= MIN_VECTOR_SIZE
|
|
&& bounds.3 - bounds.1 >= MIN_VECTOR_SIZE
|
|
{
|
|
candidate
|
|
} else {
|
|
original.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Creates a stable rectangle fixture for transform invariant tests.
|
|
fn rect() -> VectorShape {
|
|
VectorShape::Rect {
|
|
name: String::new(),
|
|
x1: 10.0,
|
|
y1: 20.0,
|
|
x2: 110.0,
|
|
y2: 70.0,
|
|
stroke: 2.0,
|
|
color: [1, 2, 3, 255],
|
|
fill_color: [4, 5, 6, 255],
|
|
fill: true,
|
|
radius: 0.0,
|
|
angle: 0.0,
|
|
opacity: 1.0,
|
|
hardness: 1.0,
|
|
}
|
|
}
|
|
|
|
/// Verifies topmost-first overlap selection and deterministic repeated-click cycling.
|
|
#[test]
|
|
fn overlap_selection_cycles_without_external_counter() {
|
|
let hits = [1, 3, 8];
|
|
assert_eq!(cycle_selection(&hits, None), Some(8));
|
|
assert_eq!(cycle_selection(&hits, Some(8)), Some(3));
|
|
assert_eq!(cycle_selection(&hits, Some(3)), Some(1));
|
|
assert_eq!(cycle_selection(&hits, Some(1)), Some(8));
|
|
assert_eq!(cycle_selection(&[], Some(1)), None);
|
|
}
|
|
|
|
/// Verifies handle targets occupy the same screen radius at low and high zoom.
|
|
#[test]
|
|
fn handle_hit_area_is_screen_constant_across_zoom() {
|
|
let bounds = (10.0, 20.0, 110.0, 70.0);
|
|
assert_eq!(
|
|
hit_test_handle(bounds, 0.0, (16.0, 20.0), 1.0),
|
|
VectorEditHandle::TopLeft
|
|
);
|
|
assert_eq!(
|
|
hit_test_handle(bounds, 0.0, (10.6, 20.0), 10.0),
|
|
VectorEditHandle::TopLeft
|
|
);
|
|
assert_eq!(
|
|
hit_test_handle(bounds, 0.0, (10.8, 20.0), 10.0),
|
|
VectorEditHandle::Move
|
|
);
|
|
}
|
|
|
|
/// Verifies the visible rotation handle and its hit target remain 24 screen pixels above the
|
|
/// selected shape at low zoom, where the previous drawing path incorrectly moved it closer.
|
|
#[test]
|
|
fn rotation_handle_offset_is_screen_constant_at_low_zoom() {
|
|
let bounds = (10.0, 20.0, 110.0, 70.0);
|
|
let zoom = 0.25;
|
|
let rotation_point = (60.0, 20.0 - ROTATE_OFFSET_PX / zoom);
|
|
|
|
assert_eq!(
|
|
hit_test_handle(bounds, 0.0, rotation_point, zoom),
|
|
VectorEditHandle::Rotate
|
|
);
|
|
assert_eq!((20.0 - rotation_point.1) * zoom, ROTATE_OFFSET_PX);
|
|
}
|
|
|
|
/// Verifies all eight handles preserve normalized, minimum-sized bounds under extreme drags.
|
|
#[test]
|
|
fn every_resize_handle_prevents_inversion_and_nan() {
|
|
let handles = [
|
|
VectorEditHandle::TopLeft,
|
|
VectorEditHandle::Top,
|
|
VectorEditHandle::TopRight,
|
|
VectorEditHandle::Left,
|
|
VectorEditHandle::Right,
|
|
VectorEditHandle::BottomLeft,
|
|
VectorEditHandle::Bottom,
|
|
VectorEditHandle::BottomRight,
|
|
];
|
|
for handle in handles {
|
|
for delta in [
|
|
(10_000.0, 10_000.0),
|
|
(-10_000.0, -10_000.0),
|
|
(f32::NAN, 1.0),
|
|
] {
|
|
let bounds = resize_bounds((10.0, 20.0, 110.0, 70.0), handle, delta, false, false);
|
|
assert!(finite4(bounds), "{handle:?}: {bounds:?}");
|
|
assert!(
|
|
bounds.2 - bounds.0 >= MIN_VECTOR_SIZE,
|
|
"{handle:?}: {bounds:?}"
|
|
);
|
|
assert!(
|
|
bounds.3 - bounds.1 >= MIN_VECTOR_SIZE,
|
|
"{handle:?}: {bounds:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verifies Shift preserves aspect and Alt keeps the original center fixed.
|
|
#[test]
|
|
fn shift_aspect_and_alt_center_constraints_hold() {
|
|
let original = (10.0, 20.0, 110.0, 70.0);
|
|
let resized = resize_bounds(
|
|
original,
|
|
VectorEditHandle::BottomRight,
|
|
(50.0, 10.0),
|
|
true,
|
|
true,
|
|
);
|
|
let ratio = (resized.2 - resized.0) / (resized.3 - resized.1);
|
|
assert!((ratio - 2.0).abs() < 0.0001);
|
|
assert!(((resized.0 + resized.2) * 0.5 - 60.0).abs() < 0.0001);
|
|
assert!(((resized.1 + resized.3) * 0.5 - 45.0).abs() < 0.0001);
|
|
}
|
|
|
|
/// Verifies drag output is based on the original snapshot and invalid pointers are harmless.
|
|
#[test]
|
|
fn drag_is_deterministic_and_rejects_non_finite_input() {
|
|
let session = VectorDragSession {
|
|
layer_id: 7,
|
|
shape_index: 0,
|
|
handle: VectorEditHandle::Move,
|
|
pointer_start: (5.0, 5.0),
|
|
original: rect(),
|
|
};
|
|
let moved = transform_shape(&session, (15.0, 25.0), false, false, false);
|
|
assert_eq!(moved.normalized_bounds(), (20.0, 40.0, 120.0, 90.0));
|
|
assert_eq!(
|
|
transform_shape(&session, (f32::NAN, 0.0), false, false, false),
|
|
rect()
|
|
);
|
|
}
|
|
|
|
/// Verifies rotated handle hit testing operates in shape-local coordinates.
|
|
#[test]
|
|
fn rotated_handle_hit_test_uses_rotated_position() {
|
|
let bounds = (10.0, 20.0, 110.0, 70.0);
|
|
let center = (60.0, 45.0);
|
|
let angle = std::f32::consts::FRAC_PI_2;
|
|
let local = (10.0, 20.0);
|
|
let rotated = (
|
|
center.0 - (local.1 - center.1),
|
|
center.1 + (local.0 - center.0),
|
|
);
|
|
assert_eq!(
|
|
hit_test_handle(bounds, angle, rotated, 2.0),
|
|
VectorEditHandle::TopLeft
|
|
);
|
|
}
|
|
}
|