mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d1cba7fdd1
commit
fc6dc4f2d6
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user