refactor(ui): drop dead and test-only image geometry

HandleId duplicated ImageHandle with conversion tables both ways; the
image engine now takes ImageHandle, which also carries the cursor icon,
and the crop grips map through one GRIPS table instead of two index
matches. ImageObb loses from_center_size_angle, scale_proportional,
project_delta, new, handle_id, handles_with_ids and the width, height
and theta fields, which only the test of scale_proportional read.
reset_crop, RESIZE_ACCENT, image_owns_pointer and DragTarget::CropHandle
had no callers, and turn_degrees is test-only. The image engine is
private and its unused re-exports are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-01 12:35:35 +01:00
co-authored by Claude Opus 5.5
parent d1cba7fdd1
commit fc6dc4f2d6
5 changed files with 101 additions and 493 deletions
+9 -167
View File
@@ -146,48 +146,12 @@ pub struct ImageObb {
pub s_lc: Pos2,
pub s_rot: Pos2,
pub screen_center: Pos2,
pub width: f32,
pub height: f32,
pub theta: f32,
pub u_axis: (f64, f64),
pub v_axis: (f64, f64),
pub is_rotated: bool,
}
impl ImageObb {
/// Calculates the 4 rotated corners manually using center, width, height, and rotation angle (θ).
/// Local axes: `u_axis = (cos θ, sin θ)` and `v_axis = (-sin θ, cos θ)`.
/// Places the 8 resize handles exactly at these rotated vertices and midpoints.
pub fn from_center_size_angle(center: Pos2, width: f32, height: f32, theta: f32) -> Self {
let cos_t = theta.cos();
let sin_t = theta.sin();
let u_axis = (cos_t as f64, sin_t as f64);
let v_axis = (-sin_t as f64, cos_t as f64);
let u_vec = vec2(cos_t, sin_t);
let v_vec = vec2(-sin_t, cos_t);
let hw = width * 0.5;
let hh = height * 0.5;
let s00 = center - u_vec * hw - v_vec * hh;
let s10 = center + u_vec * hw - v_vec * hh;
let s11 = center + u_vec * hw + v_vec * hh;
let s01 = center - u_vec * hw + v_vec * hh;
let s_bc = center - v_vec * hh;
let s_rc = center + u_vec * hw;
let s_tc = center + v_vec * hh;
let s_lc = center - u_vec * hw;
let s_rot = s_tc + v_vec * 24.0;
let is_rotated = theta.abs() > 1e-4;
Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center: center, width, height, theta, u_axis, v_axis, is_rotated }
}
pub fn new(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
Self::from_matrix(matrix, xf, info, page)
}
pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
let m = crate::edit_text_ui::Matrix(matrix);
let p00 = m.apply(0.0, 0.0);
@@ -216,7 +180,6 @@ impl ImageObb {
let h_v = (c * c + d * d).sqrt().max(1e-6);
let u_axis = (a / w_u, b / w_u);
let v_axis = (c / h_v, d / h_v);
let theta = (b).atan2(a) as f32;
let is_rotated = b.abs() > 1e-4 || c.abs() > 1e-4;
Self {
@@ -230,9 +193,6 @@ impl ImageObb {
s_lc,
s_rot,
screen_center,
width: (s10 - s00).length(),
height: (s01 - s00).length(),
theta,
u_axis,
v_axis,
is_rotated,
@@ -253,15 +213,16 @@ impl ImageObb {
pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
[
(ImageHandle::TopLeft, self.s01),
(ImageHandle::TopCenter, self.s_tc),
(ImageHandle::TopRight, self.s11),
(ImageHandle::RightCenter, self.s_rc),
(ImageHandle::BottomRight, self.s10),
(ImageHandle::BottomCenter, self.s_bc),
(ImageHandle::BottomLeft, self.s00),
(ImageHandle::LeftCenter, self.s_lc),
ImageHandle::TopLeft,
ImageHandle::TopCenter,
ImageHandle::TopRight,
ImageHandle::RightCenter,
ImageHandle::BottomRight,
ImageHandle::BottomCenter,
ImageHandle::BottomLeft,
ImageHandle::LeftCenter,
]
.map(|h| (h, self.handle_pos(h)))
}
pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
@@ -277,127 +238,9 @@ impl ImageObb {
}
}
pub fn handle_id(&self, base_id: egui::Id, handle: ImageHandle) -> egui::Id {
base_id.with(match handle {
ImageHandle::TopLeft => "handle_tl",
ImageHandle::TopCenter => "handle_tc",
ImageHandle::TopRight => "handle_tr",
ImageHandle::RightCenter => "handle_rc",
ImageHandle::BottomRight => "handle_br",
ImageHandle::BottomCenter => "handle_bc",
ImageHandle::BottomLeft => "handle_bl",
ImageHandle::LeftCenter => "handle_lc",
})
}
pub fn handles_with_ids(&self, base_id: egui::Id) -> [(ImageHandle, Pos2, egui::Id); 8] {
[
(ImageHandle::TopLeft, self.s01, self.handle_id(base_id, ImageHandle::TopLeft)),
(ImageHandle::TopCenter, self.s_tc, self.handle_id(base_id, ImageHandle::TopCenter)),
(ImageHandle::TopRight, self.s11, self.handle_id(base_id, ImageHandle::TopRight)),
(ImageHandle::RightCenter, self.s_rc, self.handle_id(base_id, ImageHandle::RightCenter)),
(ImageHandle::BottomRight, self.s10, self.handle_id(base_id, ImageHandle::BottomRight)),
(ImageHandle::BottomCenter, self.s_bc, self.handle_id(base_id, ImageHandle::BottomCenter)),
(ImageHandle::BottomLeft, self.s00, self.handle_id(base_id, ImageHandle::BottomLeft)),
(ImageHandle::LeftCenter, self.s_lc, self.handle_id(base_id, ImageHandle::LeftCenter)),
]
}
pub fn corners(&self) -> [Pos2; 4] {
[self.s00, self.s10, self.s11, self.s01]
}
/// Resizes the OBB proportionally along its local axes using projected mouse delta,
/// locking the diagonally opposite corner as the fixed anchor point.
pub fn scale_proportional(&self, handle: ImageHandle, delta: Vec2, min_size: f32) -> Self {
let u_diff = self.s10 - self.s00;
let v_diff = self.s01 - self.s00;
let u_len = u_diff.length();
let v_len = v_diff.length();
let u_vec = if u_len > 1e-4 { u_diff / u_len } else { vec2(1.0, 0.0) };
let v_vec = if v_len > 1e-4 { v_diff / v_len } else { vec2(0.0, 1.0) };
let delta_u = delta.dot(u_vec);
let delta_v = delta.dot(v_vec);
let min_s = min_size.max(5.0);
match handle {
ImageHandle::TopRight => {
let anchor = self.s00;
let s_u = (self.width + delta_u) / self.width.max(1e-4);
let s_v = (self.height + delta_v) / self.height.max(1e-4);
let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
let nw = self.width * s;
let nh = self.height * s;
let new_center = anchor + u_vec * (nw * 0.5) + v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, nw, nh, self.theta)
}
ImageHandle::TopLeft => {
let anchor = self.s10;
let s_u = (self.width - delta_u) / self.width.max(1e-4);
let s_v = (self.height + delta_v) / self.height.max(1e-4);
let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
let nw = self.width * s;
let nh = self.height * s;
let new_center = anchor - u_vec * (nw * 0.5) + v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, nw, nh, self.theta)
}
ImageHandle::BottomLeft => {
let anchor = self.s11;
let s_u = (self.width - delta_u) / self.width.max(1e-4);
let s_v = (self.height - delta_v) / self.height.max(1e-4);
let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
let nw = self.width * s;
let nh = self.height * s;
let new_center = anchor - u_vec * (nw * 0.5) - v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, nw, nh, self.theta)
}
ImageHandle::BottomRight => {
let anchor = self.s01;
let s_u = (self.width + delta_u) / self.width.max(1e-4);
let s_v = (self.height - delta_v) / self.height.max(1e-4);
let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v };
let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4));
let nw = self.width * s;
let nh = self.height * s;
let new_center = anchor + u_vec * (nw * 0.5) - v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, nw, nh, self.theta)
}
ImageHandle::RightCenter => {
let anchor = self.s_lc;
let nw = (self.width + delta_u).max(min_s);
let new_center = anchor + u_vec * (nw * 0.5);
Self::from_center_size_angle(new_center, nw, self.height, self.theta)
}
ImageHandle::LeftCenter => {
let anchor = self.s_rc;
let nw = (self.width - delta_u).max(min_s);
let new_center = anchor - u_vec * (nw * 0.5);
Self::from_center_size_angle(new_center, nw, self.height, self.theta)
}
ImageHandle::TopCenter => {
let anchor = self.s_bc;
let nh = (self.height + delta_v).max(min_s);
let new_center = anchor + v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, self.width, nh, self.theta)
}
ImageHandle::BottomCenter => {
let anchor = self.s_tc;
let nh = (self.height - delta_v).max(min_s);
let new_center = anchor - v_vec * (nh * 0.5);
Self::from_center_size_angle(new_center, self.width, nh, self.theta)
}
}
}
}
/// Projects mouse delta onto local unit axes `u_axis` and `v_axis` using the dot product.
pub fn project_delta(delta: Vec2, u_axis: (f64, f64), v_axis: (f64, f64)) -> (f64, f64) {
let dx = delta.x as f64;
let dy = delta.y as f64;
(dx * u_axis.0 + dy * u_axis.1, dx * v_axis.0 + dy * v_axis.1)
}
/// The page display as it was before full screen, put back on leaving it
@@ -4245,6 +4088,5 @@ enum FieldMenu {
Duplicate(String),
}
pub use crate::image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop};
#[cfg(test)]
mod tests;
+51 -219
View File
@@ -2,125 +2,12 @@
//!
//! Contributor-original clean-room design with zero-panic safety and lockstep pixel-scale invariance.
use egui::{Color32, CursorIcon, Pos2, Rect, Stroke, Vec2, pos2, vec2};
use egui::{Color32, Pos2, Rect, Stroke, Vec2, pos2};
use crate::edit_text_ui::ImageHandle;
// ============================================================================
// 1. HANDLE DEFINITION & GEOMETRIC ANCHORS
// ============================================================================
/// The 8 anchor handles for image resizing and cropping.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HandleId {
TopLeft,
TopMid,
TopRight,
RightMid,
BottomRight,
BottomMid,
BottomLeft,
LeftMid,
}
impl HandleId {
/// Ordered list of all 8 handles for iteration.
pub const ALL: [HandleId; 8] = [
HandleId::TopLeft,
HandleId::TopMid,
HandleId::TopRight,
HandleId::RightMid,
HandleId::BottomRight,
HandleId::BottomMid,
HandleId::BottomLeft,
HandleId::LeftMid,
];
/// Returns a human-readable display name.
pub fn name(&self) -> &'static str {
match self {
HandleId::TopLeft => "Top-Left",
HandleId::TopMid => "Top-Center",
HandleId::TopRight => "Top-Right",
HandleId::RightMid => "Right-Center",
HandleId::BottomRight => "Bottom-Right",
HandleId::BottomMid => "Bottom-Center",
HandleId::BottomLeft => "Bottom-Left",
HandleId::LeftMid => "Left-Center",
}
}
/// Evaluates the handle's current 2D screen position on a given bounding box.
pub fn position(&self, rect: Rect) -> Pos2 {
match self {
HandleId::TopLeft => rect.left_top(),
HandleId::TopMid => pos2(rect.center().x, rect.top()),
HandleId::TopRight => rect.right_top(),
HandleId::RightMid => pos2(rect.right(), rect.center().y),
HandleId::BottomRight => rect.right_bottom(),
HandleId::BottomMid => pos2(rect.center().x, rect.bottom()),
HandleId::BottomLeft => rect.left_bottom(),
HandleId::LeftMid => pos2(rect.left(), rect.center().y),
}
}
/// Mouse cursor to display when hovering or dragging this handle.
pub fn cursor_icon(&self) -> CursorIcon {
match self {
HandleId::TopLeft | HandleId::BottomRight => CursorIcon::ResizeNwSe,
HandleId::TopRight | HandleId::BottomLeft => CursorIcon::ResizeNeSw,
HandleId::TopMid | HandleId::BottomMid => CursorIcon::ResizeVertical,
HandleId::LeftMid | HandleId::RightMid => CursorIcon::ResizeHorizontal,
}
}
/// Returns a description of the fixed anchor locked during dragging.
pub fn anchor_description(&self) -> &'static str {
match self {
HandleId::TopLeft => "Bottom-Right point locked",
HandleId::TopMid => "Bottom edge locked",
HandleId::TopRight => "Bottom-Left point locked",
HandleId::RightMid => "Left edge locked",
HandleId::BottomRight => "Top-Left point locked",
HandleId::BottomMid => "Top edge locked",
HandleId::BottomLeft => "Top-Right point locked",
HandleId::LeftMid => "Right edge locked",
}
}
}
impl From<HandleId> for ImageHandle {
fn from(h: HandleId) -> Self {
match h {
HandleId::TopLeft => ImageHandle::TopLeft,
HandleId::TopMid => ImageHandle::TopCenter,
HandleId::TopRight => ImageHandle::TopRight,
HandleId::RightMid => ImageHandle::RightCenter,
HandleId::BottomRight => ImageHandle::BottomRight,
HandleId::BottomMid => ImageHandle::BottomCenter,
HandleId::BottomLeft => ImageHandle::BottomLeft,
HandleId::LeftMid => ImageHandle::LeftCenter,
}
}
}
impl From<ImageHandle> for HandleId {
fn from(h: ImageHandle) -> Self {
match h {
ImageHandle::TopLeft => HandleId::TopLeft,
ImageHandle::TopCenter => HandleId::TopMid,
ImageHandle::TopRight => HandleId::TopRight,
ImageHandle::RightCenter => HandleId::RightMid,
ImageHandle::BottomRight => HandleId::BottomRight,
ImageHandle::BottomCenter => HandleId::BottomMid,
ImageHandle::BottomLeft => HandleId::BottomLeft,
ImageHandle::LeftCenter => HandleId::LeftMid,
}
}
}
// ============================================================================
// 2. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP
// 1. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP
// ============================================================================
/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects.
@@ -137,7 +24,7 @@ pub const MIN_RECT_SIZE: f32 = 10.0;
/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate.
/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is
/// completely unaffected.
pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
pub fn apply_resize(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
return image_rect;
}
@@ -146,7 +33,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
match handle {
// Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y)
HandleId::TopLeft => {
ImageHandle::TopLeft => {
let anchor = max;
let target = min + delta;
let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE);
@@ -155,8 +42,8 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
min.y = anchor.y - new_h;
}
// Dragging TopMid: Fixed Anchor = Bottom edge (max.y)
HandleId::TopMid => {
// Dragging TopCenter: Fixed Anchor = Bottom edge (max.y)
ImageHandle::TopCenter => {
let anchor_y = max.y;
let target_y = min.y + delta.y;
let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
@@ -164,7 +51,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
}
// Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y)
HandleId::TopRight => {
ImageHandle::TopRight => {
let anchor_x = min.x;
let anchor_y = max.y;
let target_x = max.x + delta.x;
@@ -175,8 +62,8 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
min.y = anchor_y - new_h;
}
// Dragging RightMid: Fixed Anchor = Left edge (min.x)
HandleId::RightMid => {
// Dragging RightCenter: Fixed Anchor = Left edge (min.x)
ImageHandle::RightCenter => {
let anchor_x = min.x;
let target_x = max.x + delta.x;
let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
@@ -184,7 +71,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
}
// Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y)
HandleId::BottomRight => {
ImageHandle::BottomRight => {
let anchor = min;
let target = max + delta;
let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE);
@@ -193,8 +80,8 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
max.y = anchor.y + new_h;
}
// Dragging BottomMid: Fixed Anchor = Top edge (min.y)
HandleId::BottomMid => {
// Dragging BottomCenter: Fixed Anchor = Top edge (min.y)
ImageHandle::BottomCenter => {
let anchor_y = min.y;
let target_y = max.y + delta.y;
let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
@@ -202,7 +89,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
}
// Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y)
HandleId::BottomLeft => {
ImageHandle::BottomLeft => {
let anchor_x = max.x;
let anchor_y = min.y;
let target_x = min.x + delta.x;
@@ -213,8 +100,8 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
max.y = anchor_y + new_h;
}
// Dragging LeftMid: Fixed Anchor = Right edge (max.x)
HandleId::LeftMid => {
// Dragging LeftCenter: Fixed Anchor = Right edge (max.x)
ImageHandle::LeftCenter => {
let anchor_x = max.x;
let target_x = min.x + delta.x;
let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
@@ -233,7 +120,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
/// For corner handles, uses the axis with the larger relative change to scale both
/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`.
/// For mid-edge handles, delegates to `apply_resize`.
pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
pub fn apply_resize_proportional(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
return image_rect;
}
@@ -241,7 +128,7 @@ pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2
let h0 = image_rect.height().max(1.0);
match handle {
HandleId::TopLeft => {
ImageHandle::TopLeft => {
let horizontal = -delta.x / w0;
let vertical = -delta.y / h0;
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
@@ -249,7 +136,7 @@ pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2
let (w, h) = (w0 * k, h0 * k);
Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max)
}
HandleId::TopRight => {
ImageHandle::TopRight => {
let horizontal = delta.x / w0;
let vertical = -delta.y / h0;
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
@@ -257,7 +144,7 @@ pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2
let (w, h) = (w0 * k, h0 * k);
Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y))
}
HandleId::BottomLeft => {
ImageHandle::BottomLeft => {
let horizontal = -delta.x / w0;
let vertical = delta.y / h0;
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
@@ -265,7 +152,7 @@ pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2
let (w, h) = (w0 * k, h0 * k);
Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h))
}
HandleId::BottomRight => {
ImageHandle::BottomRight => {
let horizontal = delta.x / w0;
let vertical = delta.y / h0;
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
@@ -287,7 +174,7 @@ pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2
/// 4. The diagonally opposite point/edge is strictly locked as the fixed anchor.
///
/// Returns: `(new_image_rect, new_uv_rect)`
pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2) -> (Rect, Rect) {
pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: ImageHandle, delta: Vec2) -> (Rect, Rect) {
if !delta.x.is_finite() || !delta.y.is_finite() {
return (image_rect, uv_rect);
}
@@ -323,10 +210,10 @@ pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2
let mut new_uv = uv_rect;
// Identify which geometric edges are affected by this handle
let affects_left = matches!(handle, HandleId::TopLeft | HandleId::LeftMid | HandleId::BottomLeft);
let affects_right = matches!(handle, HandleId::TopRight | HandleId::RightMid | HandleId::BottomRight);
let affects_top = matches!(handle, HandleId::TopLeft | HandleId::TopMid | HandleId::TopRight);
let affects_bottom = matches!(handle, HandleId::BottomLeft | HandleId::BottomMid | HandleId::BottomRight);
let affects_left = matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft);
let affects_right = matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight);
let affects_top = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight);
let affects_bottom = matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight);
// ------------------------------------------------------------------------
// HORIZONTAL (X) CROP CALCULATION
@@ -407,36 +294,13 @@ pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2
(new_screen, new_uv)
}
/// Resets the crop boundary back to [0.0..1.0] and expands `image_rect`
/// to reveal the full image at the current screen pixel scale without shifting existing pixels.
pub fn reset_crop(image_rect: Rect, uv_rect: Rect) -> (Rect, Rect) {
if !image_rect.is_finite() || uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() {
return (image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
}
let px_per_uv_x = image_rect.width() / uv_rect.width();
let px_per_uv_y = image_rect.height() / uv_rect.height();
let full_min_x = image_rect.min.x - uv_rect.min.x * px_per_uv_x;
let full_min_y = image_rect.min.y - uv_rect.min.y * px_per_uv_y;
let full_uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let full_w = px_per_uv_x * full_uv.width();
let full_h = px_per_uv_y * full_uv.height();
let new_rect = Rect::from_min_size(pos2(full_min_x, full_min_y), vec2(full_w, full_h));
(new_rect, full_uv)
}
// ============================================================================
// 3. VISUAL RENDERING HELPERS
// 2. VISUAL RENDERING HELPERS
// ============================================================================
/// Vibrant amber/orange color for Crop Mode.
pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34);
/// Crisp blue color for Resize Mode.
pub const RESIZE_ACCENT: Color32 = Color32::from_rgb(52, 152, 219);
/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle.
pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) {
let w = rect.width();
@@ -484,17 +348,18 @@ pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_ho
}
// ============================================================================
// 4. UNIT TESTS: MATHEMATICAL VERIFICATION
// 3. UNIT TESTS: MATHEMATICAL VERIFICATION
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use egui::vec2;
#[test]
fn test_resize_bottom_right_anchor() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
let res = apply_resize(r, HandleId::BottomRight, vec2(50.0, 25.0));
let res = apply_resize(r, ImageHandle::BottomRight, vec2(50.0, 25.0));
assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed");
assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta");
}
@@ -502,7 +367,7 @@ mod tests {
#[test]
fn test_resize_top_left_anchor() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
let res = apply_resize(r, HandleId::TopLeft, vec2(20.0, 15.0));
let res = apply_resize(r, ImageHandle::TopLeft, vec2(20.0, 15.0));
assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed");
assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta");
}
@@ -510,7 +375,7 @@ mod tests {
#[test]
fn test_resize_clamping_prevents_inversion() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0));
let res = apply_resize(r, HandleId::TopLeft, vec2(500.0, 500.0));
let res = apply_resize(r, ImageHandle::TopLeft, vec2(500.0, 500.0));
assert_eq!(res.max, pos2(200.0, 200.0));
assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE));
assert!(res.width() >= MIN_RECT_SIZE);
@@ -520,7 +385,7 @@ mod tests {
#[test]
fn test_resize_top_right_anchor() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
let res = apply_resize(r, HandleId::TopRight, vec2(10.0, -10.0));
let res = apply_resize(r, ImageHandle::TopRight, vec2(10.0, -10.0));
assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed");
assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed");
assert_eq!(res.max.x, 310.0);
@@ -530,7 +395,7 @@ mod tests {
#[test]
fn test_resize_bottom_left_anchor() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
let res = apply_resize(r, HandleId::BottomLeft, vec2(-15.0, 20.0));
let res = apply_resize(r, ImageHandle::BottomLeft, vec2(-15.0, 20.0));
assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed");
assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed");
assert_eq!(res.min.x, 85.0);
@@ -541,13 +406,13 @@ mod tests {
fn test_resize_mid_edges() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
let tm = apply_resize(r, HandleId::TopMid, vec2(50.0, -30.0));
let tm = apply_resize(r, ImageHandle::TopCenter, vec2(50.0, -30.0));
assert_eq!(tm.min.x, 100.0, "X min unaffected");
assert_eq!(tm.max.x, 300.0, "X max unaffected");
assert_eq!(tm.max.y, 200.0, "Y max anchor locked");
assert_eq!(tm.min.y, 70.0);
let rm = apply_resize(r, HandleId::RightMid, vec2(40.0, 50.0));
let rm = apply_resize(r, ImageHandle::RightCenter, vec2(40.0, 50.0));
assert_eq!(rm.min.x, 100.0, "X min anchor locked");
assert_eq!(rm.max.x, 340.0);
assert_eq!(rm.min.y, 100.0, "Y unaffected");
@@ -562,7 +427,7 @@ mod tests {
let initial_px_per_uv_x = screen.width() / uv.width();
let initial_px_per_uv_y = screen.height() / uv.height();
let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::RightMid, vec2(-80.0, 0.0));
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(-80.0, 0.0));
assert_eq!(new_screen.min.x, 100.0, "Left edge anchored");
assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px");
@@ -583,7 +448,7 @@ mod tests {
let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::LeftMid, vec2(50.0, 0.0));
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(50.0, 0.0));
assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored");
assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px");
@@ -598,32 +463,20 @@ mod tests {
let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let (new_screen, new_uv) = apply_crop(screen, uv, HandleId::LeftMid, vec2(-100.0, 0.0));
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(-100.0, 0.0));
assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0");
assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position");
let (new_screen2, new_uv2) = apply_crop(screen, uv, HandleId::RightMid, vec2(100.0, 0.0));
let (new_screen2, new_uv2) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(100.0, 0.0));
assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0");
assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position");
}
#[test]
fn test_reset_crop() {
let screen = Rect::from_min_max(pos2(150.0, 100.0), pos2(450.0, 350.0)); // 300 x 250
let uv = Rect::from_min_max(pos2(0.25, 0.0), pos2(0.75, 1.0)); // 0.5 x 1.0
let (full_rect, full_uv) = reset_crop(screen, uv);
assert_eq!(full_uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
assert_eq!(full_rect.width(), 600.0);
assert_eq!(full_rect.height(), 250.0);
assert_eq!(full_rect.min.x, 0.0);
}
#[test]
fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() {
let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100
// TopRight corner moved horizontally by -25px (from x=200 to x=175)
let resized = apply_resize_proportional(rect, HandleId::TopRight, vec2(-25.0, 0.0));
let resized = apply_resize_proportional(rect, ImageHandle::TopRight, vec2(-25.0, 0.0));
assert_eq!(resized.min.x, 100.0, "Left anchor preserved");
assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved");
assert_eq!(resized.width(), 75.0, "Width scaled to 75%");
@@ -636,7 +489,7 @@ mod tests {
fn test_apply_resize_proportional_bottom_right_anchor_locked() {
let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200
// BottomRight moved down/right by (20px, 40px)
let resized = apply_resize_proportional(rect, HandleId::BottomRight, vec2(20.0, 40.0));
let resized = apply_resize_proportional(rect, ImageHandle::BottomRight, vec2(20.0, 40.0));
assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked");
assert_eq!(resized.width(), 120.0);
assert_eq!(resized.height(), 240.0);
@@ -647,16 +500,16 @@ mod tests {
use super::*;
use proptest::prelude::*;
fn arb_handle() -> impl Strategy<Value = HandleId> {
fn arb_handle() -> impl Strategy<Value = ImageHandle> {
prop_oneof![
Just(HandleId::TopLeft),
Just(HandleId::TopMid),
Just(HandleId::TopRight),
Just(HandleId::RightMid),
Just(HandleId::BottomRight),
Just(HandleId::BottomMid),
Just(HandleId::BottomLeft),
Just(HandleId::LeftMid),
Just(ImageHandle::TopLeft),
Just(ImageHandle::TopCenter),
Just(ImageHandle::TopRight),
Just(ImageHandle::RightCenter),
Just(ImageHandle::BottomRight),
Just(ImageHandle::BottomCenter),
Just(ImageHandle::BottomLeft),
Just(ImageHandle::LeftCenter),
]
}
@@ -696,7 +549,7 @@ mod tests {
prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
let is_corner = matches!(handle, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight);
let is_corner = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight);
if is_corner {
let orig_ratio = w / h;
let new_ratio = res.width() / res.height();
@@ -736,27 +589,6 @@ mod tests {
prop_assert!((scale_x_orig - scale_x_new).abs() < 1e-2);
}
#[test]
fn prop_reset_crop_invariants(
min_x in -1000.0f32..1000.0,
min_y in -1000.0f32..1000.0,
w in 50.0f32..500.0,
h in 50.0f32..500.0,
uv_u0 in 0.0f32..0.4,
uv_v0 in 0.0f32..0.4,
uv_w in 0.2f32..0.5,
uv_h in 0.2f32..0.5,
) {
let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h));
let (full_screen, full_uv) = reset_crop(screen, uv);
prop_assert_eq!(full_uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
prop_assert!(full_screen.is_finite());
prop_assert!(full_screen.width() >= screen.width());
prop_assert!(full_screen.height() >= screen.height());
}
#[test]
fn prop_quad_contains_never_panics(
q0_x in -1000.0f32..1000.0,
+1 -2
View File
@@ -36,10 +36,9 @@ macro_rules! tl_ctx {
}
pub mod canvas;
mod chrome;
pub mod image_engine;
mod image_engine;
pub use combine_grid::ThumbState as CombineThumb;
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
pub use image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop};
mod commands;
pub mod control;
mod menus;
+40 -52
View File
@@ -1,6 +1,6 @@
use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, pos2, user_box};
use crate::canvas::{ImageObb, PainterExt};
use crate::image_engine::{CROP_ACCENT, HandleId, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner, PicturePart};
@@ -17,7 +17,7 @@ pub(crate) struct ImageCx<'a> {
}
/// The 8 resize handles on an image (4 corners + 4 edge centers).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ImageHandle {
TopLeft,
TopCenter,
@@ -29,11 +29,35 @@ pub enum ImageHandle {
LeftCenter,
}
impl ImageHandle {
/// The resize cursor for this handle on an upright box.
pub(crate) fn cursor_icon(self) -> egui::CursorIcon {
match self {
ImageHandle::TopLeft | ImageHandle::BottomRight => egui::CursorIcon::ResizeNwSe,
ImageHandle::TopRight | ImageHandle::BottomLeft => egui::CursorIcon::ResizeNeSw,
ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::CursorIcon::ResizeVertical,
ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::CursorIcon::ResizeHorizontal,
}
}
}
/// The handles in the order [`crop_handles`] places them: corners, then the top, right, bottom
/// and left edge midpoints.
const GRIPS: [ImageHandle; 8] = [
ImageHandle::TopLeft,
ImageHandle::TopRight,
ImageHandle::BottomLeft,
ImageHandle::BottomRight,
ImageHandle::TopCenter,
ImageHandle::RightCenter,
ImageHandle::BottomCenter,
ImageHandle::LeftCenter,
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DragTarget {
Move,
Handle(ImageHandle),
CropHandle(ImageHandle),
Rotate,
}
@@ -196,17 +220,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
{
let d = p - start;
let k0 = keep_screen(at_start);
if grip < 8 {
let handle_id = match grip {
0 => HandleId::TopLeft,
1 => HandleId::TopRight,
2 => HandleId::BottomLeft,
3 => HandleId::BottomRight,
4 => HandleId::TopMid,
5 => HandleId::RightMid,
6 => HandleId::BottomMid,
_ => HandleId::LeftMid,
};
if let Some(&handle) = GRIPS.get(grip) {
let uv0 = if b.width() > 0.0 && b.height() > 0.0 {
Rect::from_min_max(
pos2(((k0.min.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.min.y - b.min.y) / b.height()).clamp(0.0, 1.0)),
@@ -215,7 +229,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
} else {
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))
};
let (new_screen, _) = apply_crop(k0, uv0, handle_id, d);
let (new_screen, _) = apply_crop(k0, uv0, handle, d);
let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height()));
let clamped = Rect::from_min_max(
pos2(new_screen.min.x.clamp(b.min.x, b.max.x - min_w), new_screen.min.y.clamp(b.min.y, b.max.y - min_h)),
@@ -353,17 +367,8 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
|| reset.is_some_and(|r| r.contains(p))
{
egui::CursorIcon::PointingHand
} else if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
match g {
0 => HandleId::TopLeft.cursor_icon(),
1 => HandleId::TopRight.cursor_icon(),
2 => HandleId::BottomLeft.cursor_icon(),
3 => HandleId::BottomRight.cursor_icon(),
4 => HandleId::TopMid.cursor_icon(),
5 => HandleId::RightMid.cursor_icon(),
6 => HandleId::BottomMid.cursor_icon(),
_ => HandleId::LeftMid.cursor_icon(),
}
} else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) {
g.cursor_icon()
} else if k.contains(p) {
if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else {
@@ -435,22 +440,6 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
clipped.add(egui::Shape::mesh(mesh));
}
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
/// applies after the pointer leaves the image/page while dragging.
#[allow(dead_code)]
pub(crate) fn image_owns_pointer(_ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
if selection.drag.is_some() || selection.crop.is_some() || selection.crop_mode {
return true;
}
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
let obb = ImageObb::from_image(image, xf, info, page);
let p = if ptr.primary_down { ptr.origin.or(ptr.pos) } else { ptr.pos };
let Some(p) = p else { return false };
obb.handles().iter().any(|(_, h)| h.distance(p) < 8.0) || obb.s_rot.distance(p) < 8.0
}
/// The selected picture's grips under `p`: its corners and its rotate knob. They are asked after
/// the paragraphs, so text on top of a picture cannot take them; `clip` is the pages' visible
/// area, which the knob stays inside.
@@ -542,7 +531,7 @@ pub(super) fn turn_degrees_delta(centre: Pos2, start: Pos2, pointer: Pos2, snap:
/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap`
/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing.
#[allow(dead_code)]
#[cfg(test)]
pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap)
}
@@ -673,9 +662,9 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
}
let mut handle_drag = None;
for (h, h_pos, handle_id) in obb.handles_with_ids(ui.scope_id().with("img_handle")) {
for (h, h_pos) in obb.handles() {
let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0));
let handle_resp = ui.interact(handle_rect, handle_id, egui::Sense::drag());
let handle_resp = ui.interact(handle_rect, ui.scope_id().with("img_handle").with(h), egui::Sense::drag());
if allow_interaction && handle_resp.drag_started() {
handle_drag = Some(h);
}
@@ -795,14 +784,14 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
if !obb.is_rotated && !matches!(drag.target, DragTarget::Rotate) {
let preview = match drag.target {
DragTarget::Handle(handle) => {
let handle_id = HandleId::from(handle);
let delta = p - drag.start;
let is_corner = matches!(handle_id, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight);
let is_corner =
matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight);
let shift = ui.input(|inp| inp.modifiers.shift);
if is_corner && !shift {
apply_resize_proportional(drag.initial_rect, handle_id, delta)
apply_resize_proportional(drag.initial_rect, handle, delta)
} else {
apply_resize(drag.initial_rect, handle_id, delta)
apply_resize(drag.initial_rect, handle, delta)
}
}
DragTarget::Move => drag.initial_rect.translate(p - drag.start),
@@ -857,7 +846,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
}
let icon = match drag.target {
DragTarget::Handle(handle) => HandleId::from(handle).cursor_icon(),
DragTarget::Handle(handle) => handle.cursor_icon(),
DragTarget::Move => egui::CursorIcon::Grabbing,
_ => egui::CursorIcon::Move,
};
@@ -992,10 +981,9 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
let mat = Matrix([new_w * u_axis.0, new_w * u_axis.1, new_h * v_axis.0, new_h * v_axis.1, p00_new.0, p00_new.1]);
(mat, icon)
}
_ => (m_init, egui::CursorIcon::Default),
};
let preview_obb = ImageObb::new(m_new.0, xf, info, page);
let preview_obb = ImageObb::from_matrix(m_new.0, xf, info, page);
if let Some(tex) = view.page_raster(page) {
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
let mut mesh = egui::Mesh::with_texture(tex);
-53
View File
@@ -7715,59 +7715,6 @@ fn rotated_image_selection_and_crop() {
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "crop mode ended");
}
#[test]
fn test_image_rotated_45_deg_obb_and_proportional_scale() {
use pdfcraft_ui_egui::ImageHandle;
use pdfcraft_ui_egui::canvas::ImageObb;
use std::f32::consts::FRAC_PI_4;
let center = egui::pos2(200.0, 200.0);
let width = 100.0_f32;
let height = 50.0_f32;
let theta = FRAC_PI_4;
let obb = ImageObb::from_center_size_angle(center, width, height, theta);
// 1. Verify it outputs an OBB (not an AABB):
assert!(obb.is_rotated);
let corners = obb.corners();
assert!((corners[0].x - corners[1].x).abs() > 1.0);
assert!((corners[0].y - corners[1].y).abs() > 1.0);
assert!((corners[1].x - corners[2].x).abs() > 1.0);
assert!((corners[1].y - corners[2].y).abs() > 1.0);
let edge_u = (corners[1] - corners[0]).length();
let edge_v = (corners[3] - corners[0]).length();
assert!((edge_u - width).abs() < 1e-4, "edge_u={edge_u}, width={width}");
assert!((edge_v - height).abs() < 1e-4, "edge_v={edge_v}, height={height}");
let dot = (corners[1] - corners[0]).dot(corners[3] - corners[0]);
assert!(dot.abs() < 1e-4, "adjacent edges must be perpendicular for OBB");
let handles = obb.handles();
assert_eq!(handles.len(), 8);
// 2. Drag TopRight handle with a pointer delta along local axes:
let delta = egui::vec2(10.0 * theta.cos(), 10.0 * theta.sin());
let anchor_before = obb.s00;
let scaled_obb = obb.scale_proportional(ImageHandle::TopRight, delta, 5.0);
// The diagonally opposite corner (s00) MUST remain locked as the fixed anchor point:
assert!((scaled_obb.s00.x - anchor_before.x).abs() < 1e-4);
assert!((scaled_obb.s00.y - anchor_before.y).abs() < 1e-4);
// Dimensions scale proportionally along local axes (aspect ratio preserved):
let new_aspect = scaled_obb.width / scaled_obb.height;
let old_aspect = width / height;
assert!((new_aspect - old_aspect).abs() < 1e-4, "aspect ratio preserved: {new_aspect} vs {old_aspect}");
assert!(scaled_obb.width > width, "width increased");
assert!(scaled_obb.height > height, "height increased proportionally");
// Rotation angle is preserved without skewing:
assert!((scaled_obb.theta - theta).abs() < 1e-4);
}
trait RectExt {
fn width(&self) -> f64;
}