Add Vertical and Horizontal perspective sliders to Compose

The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.

Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
This commit is contained in:
2026-09-24 22:13:12 -04:00
parent 5a500118ae
commit 114d979397
7 changed files with 253 additions and 50 deletions
+92
View File
@@ -415,6 +415,13 @@ impl DevelopSession {
/// user put it — which is correct for the drag and leaves black wedges in
/// the corners of the finished photograph.
///
/// TRACES: FR-DEV-20
/// **A keystone gesture ends here too.** A keystone alone never exposes a
/// corner, but it reshapes the area a *straightened* frame has pixels
/// in, so the crop that avoided the corners before it may not avoid them
/// after — and one it had to shrink may now have room to grow back.
/// [`dr_pipeline::Framing::max_inscribed_crop`] accounts for both.
///
/// This is the correction, and it runs when the gesture **finishes**.
/// Applied continuously it would fight the drag, shrinking the crop on
/// every frame of the slider.
@@ -464,6 +471,33 @@ impl DevelopSession {
);
}
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, as the panel shows them.
pub fn keystone(&self) -> (f32, f32) {
self.graph.framing().keystone()
}
/// TRACES: FR-DEV-20
/// Set the vertical keystone. Positive spreads the top of the frame.
///
/// Recorded as a parameter step, like the angle, so a drag collapses into
/// one entry in the history rather than one per frame of the slider.
pub fn set_keystone_v(&mut self, amount: f32) {
self.set_framing_param(dr_pipeline::framing::KEYSTONE_V, amount);
}
/// TRACES: FR-DEV-20
/// Set the horizontal keystone. Positive spreads the right-hand side.
pub fn set_keystone_h(&mut self, amount: f32) {
self.set_framing_param(dr_pipeline::framing::KEYSTONE_H, amount);
}
fn set_framing_param(&mut self, param: dr_pipeline::ParamId, value: f32) {
self.graph.set_param(dr_pipeline::framing::ID, param, value);
self.history
.record(&self.graph, Edit::Param(dr_pipeline::framing::ID, param));
}
/// Whether the framing currently changes the image — what lights the
/// section's modified dot and enables its reset.
///
@@ -998,6 +1032,64 @@ mod tests {
);
}
/// TRACES: FR-DEV-20
/// The keystone sliders are edits with a history, a reset, and a crop
/// that follows them.
#[test]
fn a_keystone_is_an_undoable_edit_the_crop_follows() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_keystone_v(60.0);
assert_eq!(session.keystone(), (60.0, 0.0));
assert!(session.framing_edits_image());
assert!(
session.undo_label().contains("Vertical Perspective"),
"{}",
session.undo_label()
);
// Alone it needs no crop: the end of the gesture leaves it full.
session.auto_crop_to_angle();
assert!(session.crop().is_full(), "{:?}", session.crop());
// Straightened as well, the end of the gesture pulls the crop inside
// the area both together leave defined — the core's inscribed rect,
// at the shape of the crop the user chose (here the whole frame).
session.set_angle(6.0);
session.auto_crop_to_angle();
let with_both = session.crop();
assert_eq!(
with_both,
CropRect::default().fitted_into(session.max_inscribed_crop())
);
assert!(!with_both.is_full());
// Releasing the keystone refits from the crop the user chose, so the
// crop grows back to the straightening's own.
session.set_keystone_v(0.0);
session.auto_crop_to_angle();
let straightened_only = session.crop();
assert_eq!(
straightened_only,
CropRect::default().fitted_into(session.max_inscribed_crop())
);
assert_ne!(straightened_only, with_both);
// And it is one reset away from neutral, like the rest of framing.
session.set_keystone_h(-30.0);
session.reset_framing();
assert_eq!(session.keystone(), (0.0, 0.0));
assert!(!session.framing_edits_image());
assert!(session.undo());
assert_eq!(
session.keystone(),
(0.0, -30.0),
"undo restores the keystone"
);
}
#[test]
fn four_quarter_turns_return_a_crop_where_it_started() {
// The property that makes rotation safe to repeat: a user who turns