Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs
T

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
);
}
}