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:
2026-08-29 13:49:19 +02:00
co-authored by Claude Opus 5
parent e6ad906bc1
commit d9eb8faffd
6 changed files with 233 additions and 32 deletions
+34
View File
@@ -3315,6 +3315,40 @@ impl DevelopSession {
.record(&self.graph, Edit::Action(labels::step::FLIP_V));
}
/// TRACES: FR-DEV-3
/// Pull the crop inside the area a straightening angle leaves defined.
///
/// Turning a rectangle inside its own bounds exposes the corners: there is
/// no source pixel out there and the shader renders it black. Nothing in
/// the render prevents it — a free angle deliberately does *not* change the
/// output size, so that straightening a horizon leaves the frame where the
/// user put it — which is correct for the drag and leaves black wedges in
/// the corners of the finished photograph.
///
/// This is the correction, and it runs when the gesture **finishes**.
/// Applied continuously it would shrink the crop on every frame 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. Once per gesture, that is
/// bounded: the crop is only ever as small as the angles actually rested
/// at, and undo steps back through them.
///
/// The crop keeps its own shape — so a locked ratio survives — and keeps
/// the side of the frame it was on; see [`CropRect::fitted_into`] for why
/// it is not simply replaced by the inscribed rectangle.
pub fn auto_crop_to_angle(&mut self) {
let (sw, sh) = self.demosaiced.size();
let bound = self.graph.framing().max_inscribed_crop(sw, sh);
// Upright, so every corner is already defined and there is nothing to
// pull in from. Returning early rather than fitting into a full rect
// matters: it keeps letting go of a slider at zero from touching the
// crop at all.
if bound.is_full() {
return;
}
self.set_crop(self.graph.crop().fitted_into(bound));
}
/// Set the straightening angle, in degrees.
pub fn set_angle(&mut self, degrees: f32) {
self.graph.set_param(
+26
View File
@@ -2684,6 +2684,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
redraw(&w);
});
}
{
// TRACES: FR-DEV-3
// **The end of a straightening gesture crops away the corners it
// exposed.** A free angle does not change the output size, by design —
// that is what leaves the frame where the user put it while the slider
// moves — so the corners of the finished photograph would otherwise be
// black wedges of undefined area.
//
// Only on release, and only on release. Doing it per frame ratchets
// the crop down through every angle the slider passed through rather
// than the one it stopped at; see `auto_crop_to_angle`.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_straighten_drag_changed(move |dragging| {
if dragging {
return;
}
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.auto_crop_to_angle();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// The geometry section's reset: crop, angle, rotation and flips back
// to neutral, leaving every colour adjustment where it is. The panel's
+5
View File
@@ -512,6 +512,10 @@ export component GeometryPanel inherits Rectangle {
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
/// The straightening gesture began or ended. What the auto-crop hangs
/// off — see `DevelopSession::auto_crop_to_angle` for why it must be the
/// end of the drag and not every frame of it.
callback angle-drag-changed(bool);
callback angle-reset();
callback aspect-picked(int);
callback portrait-toggled();
@@ -604,6 +608,7 @@ export component GeometryPanel inherits Rectangle {
maximum: root.max-straighten;
unit: "°";
changed(v) => { root.angle-changed(v); }
drag-changed(on) => { root.angle-drag-changed(on); }
reset => { root.angle-reset(); }
}
+10
View File
@@ -161,6 +161,13 @@ export component AppWindow inherits Window {
callback flip-h-toggled();
callback flip-v-toggled();
callback straighten-changed(float);
/// TRACES: FR-DEV-3
/// The straightening gesture began (`true`) or ended (`false`).
///
/// Reported separately from the value because the auto-crop that follows a
/// straighten has to happen once, at the end — see
/// `DevelopSession::auto_crop_to_angle`.
callback straighten-drag-changed(bool);
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback framing-reset();
@@ -2158,6 +2165,9 @@ in property <bool> panel-visible: true;
flip-h-toggled => { root.flip-h-toggled(); }
flip-v-toggled => { root.flip-v-toggled(); }
angle-changed(v) => { root.straighten-changed(v); }
angle-drag-changed(on) => {
root.straighten-drag-changed(on);
}
angle-reset => { root.straighten-changed(0); }
reset => { root.framing-reset(); }
}