Crop away the corners a straighten exposed, once the slider is let go
Turning a rectangle inside its own bounds exposes its corners: there is no source pixel out there, and the shader renders it black. Nothing in the render prevents that, deliberately — a free angle does not change the output size, which is what leaves the frame where the user put it while the slider moves. Correct during the drag; four black wedges on the finished photograph. Letting go of the slider now pulls the crop inside the area the angle leaves defined. `Framing::max_inscribed_crop` already computed that bound and had no caller; this is the caller its doc comment described. **Once, at the end of the gesture.** Applied per frame it would shrink the crop on every step of the slider and never grow it back, so a user who overshot to 20° and came back to 3° would be left with a crop ratcheted down by the excursion rather than by the angle they settled on. Per gesture it is bounded by the angles actually rested at, and undo steps back through them. **The crop is fitted into the bound, not replaced by it.** A crop placed deliberately off-centre is a decision, and an automatic correction that recentred it would undo the user's work to fix a problem they did not have. `CropRect::fitted_into` scales only as far as the bound demands and then slides the rect the shortest distance needed to be inside — so a ratio locked in the crop panel survives the straighten too, since the shape is never touched. It returns the rect unchanged, bit for bit, when nothing needed to move. That matters more than it looks: this runs on every release of the slider, including releases at zero, and a rect that drifted by a rounding error each time would be an edit recorded for no reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,56 @@ impl CropRect {
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The largest copy of this rect, at its own shape, sitting inside
|
||||
/// `bound`.
|
||||
///
|
||||
/// Scaled down only as far as `bound` demands and then slid inside it,
|
||||
/// rather than replaced by `bound` or centred within it. Both of those
|
||||
/// throw away a composition: a crop placed deliberately off-centre is a
|
||||
/// decision, and an automatic correction that recentres it has undone the
|
||||
/// user's work to fix a problem they did not have.
|
||||
pub fn fitted_into(self, bound: Self) -> Self {
|
||||
let rect = self.normalised();
|
||||
let bound = bound.normalised();
|
||||
|
||||
let shrink = (bound.width / rect.width)
|
||||
.min(bound.height / rect.height)
|
||||
.min(1.0);
|
||||
let shrink = if shrink.is_finite() && shrink > 0.0 {
|
||||
shrink
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let (w, h) = (rect.width * shrink, rect.height * shrink);
|
||||
|
||||
// Shrunk about its own centre, so what was framed stays framed — but
|
||||
// only when it actually shrank. Routing the untouched case through
|
||||
// the same arithmetic moves the origin by a rounding error, and a
|
||||
// rect that is already inside its bound must come back *identical*:
|
||||
// this is called on every straighten, and a rect that drifts a
|
||||
// millionth each time is an edit recorded for no reason.
|
||||
let (x, y) = if shrink < 1.0 {
|
||||
(
|
||||
rect.x + (rect.width - w) * 0.5,
|
||||
rect.y + (rect.height - h) * 0.5,
|
||||
)
|
||||
} else {
|
||||
(rect.x, rect.y)
|
||||
};
|
||||
|
||||
Self {
|
||||
// `max` on the upper limit for the same reason `normalised`
|
||||
// orders its bounds that way: rounding can put the far edge a
|
||||
// hair *below* the near one, and `clamp` panics on an inverted
|
||||
// range rather than resolving it.
|
||||
x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)),
|
||||
y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
|
||||
@@ -1806,6 +1856,82 @@ mod tests {
|
||||
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rect_already_inside_its_bound_is_left_where_it_is() {
|
||||
let bound = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.8,
|
||||
height: 0.8,
|
||||
};
|
||||
let rect = CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.3,
|
||||
};
|
||||
assert_eq!(rect.fitted_into(bound), rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_into_a_bound_keeps_the_shape_and_the_side_it_was_on() {
|
||||
// A crop placed deliberately off-centre is a decision. Recentring it
|
||||
// to solve a problem the user did not have would undo their work.
|
||||
let bound = CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
};
|
||||
let rect = CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.8,
|
||||
height: 0.4,
|
||||
};
|
||||
let fitted = rect.fitted_into(bound);
|
||||
|
||||
assert!(
|
||||
(fitted.width / fitted.height - rect.width / rect.height).abs() < 1e-4,
|
||||
"shape changed: {fitted:?}"
|
||||
);
|
||||
assert!(
|
||||
fitted.x >= bound.x - 1e-5
|
||||
&& fitted.y >= bound.y - 1e-5
|
||||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||||
"{fitted:?} is not inside {bound:?}"
|
||||
);
|
||||
// It came from the top-left, so it should still be against those
|
||||
// edges rather than centred in the bound.
|
||||
assert!((fitted.x - bound.x).abs() < 1e-5, "{fitted:?}");
|
||||
assert!((fitted.y - bound.y).abs() < 1e-5, "{fitted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_crop_still_fits_inside_the_straightened_safe_area() {
|
||||
// The two new pieces meet here: an angle shrinks the safe area, and a
|
||||
// ratio the user locked has to survive being fitted into it.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 7.0);
|
||||
let bound = f.max_inscribed_crop(6000, 4000);
|
||||
|
||||
let locked = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||||
let fitted = locked.fitted_into(bound);
|
||||
|
||||
assert!(
|
||||
(pixel_ratio(fitted, 6000, 4000) - 1.0).abs() < 1e-3,
|
||||
"the lock did not survive the fit: {fitted:?}"
|
||||
);
|
||||
assert!(
|
||||
fitted.x >= bound.x - 1e-5
|
||||
&& fitted.y >= bound.y - 1e-5
|
||||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||||
"{fitted:?} is not inside {bound:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_round_trip() {
|
||||
let mut f = Framing::new();
|
||||
|
||||
Reference in New Issue
Block a user