From fc6dc4f2d6aca5feef8bbcdbf7adf9930125f9aa Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:14:24 +0100 Subject: [PATCH] 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 --- crates/ui-egui/src/canvas.rs | 176 +----------- crates/ui-egui/src/image_engine.rs | 270 ++++-------------- crates/ui-egui/src/lib.rs | 3 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 92 +++--- crates/ui-egui/tests/it/editing.rs | 53 ---- 5 files changed, 101 insertions(+), 493 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 52e05fc350..b554af2933 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs index a1c4124ce8..a6ebdc6074 100644 --- a/crates/ui-egui/src/image_engine.rs +++ b/crates/ui-egui/src/image_engine.rs @@ -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 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 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 { + fn arb_handle() -> impl Strategy { 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, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b5e165b20c..90076fea11 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index c37102da5b..911e314129 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -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); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 1921742ea5..2a455dd302 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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; }