Crop: drag_crop domain operation for handle drags (#295)

Dragging a crop handle past the image edge used to hand the raw rectangle to
CropGeometry::constrained, which slid the whole frame toward the centre and
moved the anchored corner. drag_crop owns the drag instead: the opposite
edges stay put, a locked ratio holds on all eight handles (edges grow about
their midline, sliding along a blocked side), the frame stops at the image
edge and slides along it, and it keeps a minimum size. Hostile numbers return
the start crop. Covered by Given/When/Then scenarios and property tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-08 10:35:46 -04:00
parent 60828c3eaf
commit 90c63af951
No known key found for this signature in database
2 changed files with 485 additions and 0 deletions

View File

@ -0,0 +1,483 @@
//! Interactive crop editing: what dragging a crop handle does.
//!
//! The crop rectangle lives in the straightened (rotated) frame and must stay inside the rotated
//! image. A drag is a *domain operation* on the crop, not a UI detail: the handle being dragged
//! decides which edges are anchored, an optional aspect lock decides the shape, and the image
//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app,
//! CLI, control channel and MCP agree.
use crate::{CropGeometry, Point, Rect};
/// Smallest crop side, as a fraction of the image's shorter side.
pub const MIN_CROP_FRACTION: f64 = 0.02;
/// Most elongated locked crop shape (width:height or its inverse) the drag supports.
pub const MAX_RATIO: f64 = 20.0;
/// A grab point on the crop frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CropHandle {
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Top,
Right,
Bottom,
Left,
/// Drag the whole frame.
Move,
}
impl CropHandle {
/// The overlay's numbering: 0..3 corners (clockwise from top-left), 4..7 edges (top, right,
/// bottom, left), 8 = move.
pub fn from_index(i: u8) -> Option<CropHandle> {
Some(match i {
0 => CropHandle::TopLeft,
1 => CropHandle::TopRight,
2 => CropHandle::BottomRight,
3 => CropHandle::BottomLeft,
4 => CropHandle::Top,
5 => CropHandle::Right,
6 => CropHandle::Bottom,
7 => CropHandle::Left,
8 => CropHandle::Move,
_ => return None,
})
}
/// Parse a command-parameter name (`"topLeft"`, `"top"`, `"move"`, …).
pub fn from_name(n: &str) -> Option<CropHandle> {
Some(match n {
"topLeft" => CropHandle::TopLeft,
"topRight" => CropHandle::TopRight,
"bottomRight" => CropHandle::BottomRight,
"bottomLeft" => CropHandle::BottomLeft,
"top" => CropHandle::Top,
"right" => CropHandle::Right,
"bottom" => CropHandle::Bottom,
"left" => CropHandle::Left,
"move" => CropHandle::Move,
_ => return None,
})
}
}
/// Drag `handle` of `start` from `grab` to `pointer` (both normalized, in the straightened frame)
/// on a `w × h` image. `aspect` is the locked width/height ratio in pixels, if any.
///
/// Guarantees (for any input, including hostile numbers):
/// - the result lies inside the rotated image;
/// - the edges opposite the handle stay where they were (a move keeps the size);
/// - with `aspect`, the frame keeps that shape on every handle (when `start` had it);
/// - the drag stops at the image edge instead of pushing the frame around, and slides along it.
pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: Point, w: f64, h: f64, aspect: Option<f64>) -> CropGeometry {
let sane = |v: f64| v.is_finite();
let r0 = start.rect;
if ![w, h, grab.x, grab.y, pointer.x, pointer.y, start.angle, r0.x0, r0.y0, r0.x1, r0.y1].iter().copied().all(sane)
|| w <= 0.0
|| h <= 0.0
|| r0.is_empty()
|| aspect.is_some_and(|a| !(a.is_finite() && a > 0.0))
{
return start;
}
// Start from a valid frame, then work in pixels of the straightened frame.
let mut base = start.constrained(w, h);
if base.angle == 0.0 {
// absorb the 1e-6 tolerance of `is_within_image` so the exact test below accepts the start
let r = base.rect;
base.rect = Rect::new(r.x0.clamp(0.0, 1.0), r.y0.clamp(0.0, 1.0), r.x1.clamp(0.0, 1.0), r.y1.clamp(0.0, 1.0));
}
let sr = base.rect_px(w, h);
// Moving further than twice the image never matters; clamping keeps the searches well-conditioned.
let (dx, dy) = (((pointer.x - grab.x) * w).clamp(-2.0 * w, 2.0 * w), ((pointer.y - grab.y) * h).clamp(-2.0 * h, 2.0 * h));
// Ratios beyond 20:1 make even the minimum frame larger than the image; treat them as 20:1.
let aspect = aspect.map(|a| a.clamp(1.0 / MAX_RATIO, MAX_RATIO));
// Unrotated, the frame must be exactly inside the image (`is_within_image` tolerates 1e-6, which
// would leak out as a sliver past the edge and, after any clamp, a shifted ratio).
let feasible = |r: Rect| {
if base.angle == 0.0 {
r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= w && r.y1 <= h
} else {
CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: base.angle }.is_within_image(w, h)
}
};
let min_side = MIN_CROP_FRACTION * w.min(h);
let out = match (handle, aspect) {
(CropHandle::Move, _) => {
let slide = |cur: Rect, ax: f64, ay: f64| {
let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t))));
cur.translate(crate::Vec2::new(ax * t, ay * t))
};
let cur = slide(sr, dx, 0.0);
slide(cur, 0.0, dy)
}
(hd, None) => {
let (mx0, mx1, my0, my1) = sides(hd);
let grow = |cur: Rect, tx: f64, ty: f64| {
// `tx`/`ty`: how far the moving edges would go; interpolate towards it
let target = Rect::new(
if mx0 { (cur.x0 + tx).min(cur.x1 - min_side) } else { cur.x0 },
if my0 { (cur.y0 + ty).min(cur.y1 - min_side) } else { cur.y0 },
if mx1 { (cur.x1 + tx).max(cur.x0 + min_side) } else { cur.x1 },
if my1 { (cur.y1 + ty).max(cur.y0 + min_side) } else { cur.y1 },
);
let at = |t: f64| lerp_rect(cur, target, t);
at(bisect(0.0, 1.0, |t| feasible(at(t))))
};
let cur = grow(sr, dx, 0.0);
grow(cur, 0.0, dy)
}
(hd, Some(a)) => {
let (mx0, _, my0, _) = sides(hd);
let (min_w, min_h) = (min_side.max(min_side * a), min_side.max(min_side / a));
let c = sr.center();
// Anchor: the opposite corner / edge midpoint. `build(n, o)` is the frame of width `n` that
// keeps it, shifted by `o` along the anchored edge (edge handles only).
let (target_w, build): (f64, Box<dyn Fn(f64, f64) -> Rect>) = match hd {
CropHandle::Top | CropHandle::Bottom => {
let (ay, sy, edge) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
let nh = (sy * (edge + dy - ay)).max(min_h);
(nh * a, Box::new(move |nw, o| vband(c.x + o, ay, sy, nw, a)))
}
CropHandle::Left | CropHandle::Right => {
let (ax, sx, edge) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
let nw = (sx * (edge + dx - ax)).max(min_w);
(nw, Box::new(move |nw, o| hband(ax, sx, c.y + o, nw, a)))
}
_ => {
let (ax, sx, ex) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
let (ay, sy, ey) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
let (wx, wy) = (sx * (ex + dx - ax), sy * (ey + dy - ay));
let nw = ((wx + wy * a) / 2.0).max(min_w);
(nw, Box::new(move |nw, _| corner_rect(ax, sx, ay, sy, nw, a)))
}
};
let target_w = target_w.min(2.0 * w.max(h * a));
let slides = matches!(hd, CropHandle::Top | CropHandle::Bottom | CropHandle::Left | CropHandle::Right);
let extent = if matches!(hd, CropHandle::Top | CropHandle::Bottom) { w } else { h };
// Frame of width `n`, centred on the anchored edge if it fits, else slid the least distance.
let place = |n: f64| -> Option<Rect> {
let at = |o: f64| build(n, o);
if feasible(at(0.0)) {
return Some(at(0.0));
}
if !slides {
return None;
}
const STEPS: u32 = 128;
let step = extent / STEPS as f64;
for k in 1..=STEPS {
for sign in [1.0, -1.0] {
let hi = sign * k as f64 * step;
if feasible(at(hi)) {
// closest feasible offset: bisect between the last infeasible and this one
let (mut bad, mut good) = (sign * (k - 1) as f64 * step, hi);
for _ in 0..40 {
let mid = (bad + good) / 2.0;
if feasible(at(mid)) {
good = mid;
} else {
bad = mid;
}
}
return Some(at(good));
}
}
}
None
};
if place(min_w).is_some() {
let n = bisect_range(min_w, target_w.max(min_w), |n| place(n).is_some());
place(n).unwrap_or(sr)
} else {
sr
}
}
};
let rect = out.scale(1.0 / w, 1.0 / h);
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start }
}
/// Which sides a handle moves: (left, right, top, bottom).
fn sides(h: CropHandle) -> (bool, bool, bool, bool) {
use CropHandle::*;
(
matches!(h, TopLeft | BottomLeft | Left),
matches!(h, TopRight | BottomRight | Right),
matches!(h, TopLeft | TopRight | Top),
matches!(h, BottomLeft | BottomRight | Bottom),
)
}
fn lerp_rect(a: Rect, b: Rect, t: f64) -> Rect {
let l = |p: f64, q: f64| p + (q - p) * t;
Rect::new(l(a.x0, b.x0), l(a.y0, b.y0), l(a.x1, b.x1), l(a.y1, b.y1))
}
/// Frame of width `nw` and ratio `a` extending from corner (`ax`, `ay`) in directions `sx`, `sy`.
fn corner_rect(ax: f64, sx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
let nh = nw / a;
Rect::from_points(Point::new(ax, ay), Point::new(ax + sx * nw, ay + sy * nh))
}
/// Frame whose top/bottom edge (at `ay`, growing in direction `sy`) is anchored, centred on `cx`.
fn vband(cx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
let nh = nw / a;
Rect::from_points(Point::new(cx - nw / 2.0, ay), Point::new(cx + nw / 2.0, ay + sy * nh))
}
/// Frame whose left/right edge (at `ax`, growing in direction `sx`) is anchored, centred on `cy`.
fn hband(ax: f64, sx: f64, cy: f64, nw: f64, a: f64) -> Rect {
let nh = nw / a;
Rect::from_points(Point::new(ax, cy - nh / 2.0), Point::new(ax + sx * nw, cy + nh / 2.0))
}
/// Largest `t` in `[lo, hi]` for which `ok` holds, given that it holds at `lo` and the set of good
/// `t` is an interval (the feasible region is convex).
fn bisect_range(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
if ok(hi) {
return hi;
}
let (mut good, mut bad) = (lo, hi);
for _ in 0..48 {
let mid = (good + bad) / 2.0;
if ok(mid) {
good = mid;
} else {
bad = mid;
}
}
good
}
fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
bisect_range(lo, hi, ok)
}
#[cfg(test)]
mod tests {
use super::*;
use CropHandle::*;
const W: f64 = 600.0;
const H: f64 = 400.0;
const EPS: f64 = 1e-6;
fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry {
CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 }
}
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option<f64>) -> CropGeometry {
drag_crop(g, hd, from, to, W, H, aspect)
}
fn px_aspect(g: &CropGeometry) -> f64 {
g.rect.width() * W / (g.rect.height() * H)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < EPS
}
// Feature: dragging a crop handle
// Scenario: a free corner drag inside the image
#[test]
fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() {
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None);
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2));
assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7));
}
// Scenario: dragging past the right edge (the weird behaviour of issue 295)
#[test]
fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() {
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None);
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect);
assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect);
assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect);
}
#[test]
fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() {
let a = 1.5;
let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a));
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect);
assert!(near(px_aspect(&g), a), "{}", px_aspect(&g));
assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS);
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect);
}
#[test]
fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() {
let a = 2.0;
let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a);
let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a));
assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays");
assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways");
assert!(near(px_aspect(&g), a));
assert!(g.rect.height() > start.rect.height());
}
#[test]
fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() {
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None);
assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8));
}
#[test]
fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() {
let start = geo(0.5, 0.25, 0.9, 0.65);
let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None);
assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4));
assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect);
assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect);
}
#[test]
fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() {
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None);
assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0);
assert!(near(g.rect.x0, 0.2));
}
#[test]
fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() {
let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5));
let c = start.rect.center();
for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] {
for aspect in [None, Some(1.5)] {
let g = drag(start, hd, c, p(3.0, -3.0), aspect);
assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect);
assert_eq!(g.angle, 12.0);
}
}
}
#[test]
fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() {
let s = geo(0.2, 0.2, 0.8, 0.8);
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s);
}
assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s);
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s);
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s);
}
// Scenario: a locked edge drag when the frame already touches the image edge (review finding)
#[test]
fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() {
let a = 1.5;
let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H));
let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a));
assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect);
assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect);
assert!(near(px_aspect(&g), a));
assert!(g.is_within_image(W, H));
}
#[test]
fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() {
let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5));
let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5));
assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5));
}
// Scenario: extreme or hostile magnitudes
#[test]
fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() {
let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H));
let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0));
assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect);
}
#[test]
fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() {
let a = 1.5;
let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H));
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a));
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect);
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None);
assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect);
}
#[test]
fn given_an_unrotated_image_then_results_never_leave_the_unit_square() {
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None);
assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect);
}
#[test]
fn handle_numbering_matches_the_overlay() {
assert_eq!(CropHandle::from_index(0), Some(TopLeft));
assert_eq!(CropHandle::from_index(6), Some(Bottom));
assert_eq!(CropHandle::from_index(8), Some(Move));
assert_eq!(CropHandle::from_index(9), None);
assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft));
assert_eq!(CropHandle::from_name("nope"), None);
}
mod props {
use super::*;
use proptest::prelude::*;
fn any_handle() -> impl Strategy<Value = CropHandle> {
(0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move))
}
proptest! {
/// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold.
#[test]
fn drag_is_always_inside_anchored_and_shaped(
angle in -45.0f64..45.0,
ratio in prop::option::of(0.3f64..4.0),
hd in any_handle(),
tx in -4.0f64..5.0, ty in -4.0f64..5.0,
shrink in 0.2f64..1.0,
) {
let base = crate::crop_fit_angle(W, H, angle, ratio);
// a smaller start crop so there is room to move
let c = base.rect.center();
let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle };
prop_assume!(start.is_within_image(W, H));
let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio);
prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect);
prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0);
let min = MIN_CROP_FRACTION * W.min(H) - 1e-6;
prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect);
if let Some(a) = ratio {
prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g));
}
// anchors: edges opposite the handle do not move
let (s, r) = (start.rect, g.rect);
let keep_l = matches!(hd, TopRight | BottomRight | Right);
let keep_r = matches!(hd, TopLeft | BottomLeft | Left);
let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom);
let keep_b = matches!(hd, TopLeft | TopRight | Top);
{
if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); }
if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); }
if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); }
if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); }
}
if hd == Move {
prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9);
}
}
#[test]
fn drag_never_panics_on_arbitrary_numbers(
x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0,
angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9,
ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(),
) {
let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio);
prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite());
}
}
}
}

View File

@ -6,10 +6,12 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crop;
mod crop_drag;
mod homography;
mod real;
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop};
pub use homography::Homography;
pub use real::{Interval, Real};