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:
@@ -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
|
||||
|
||||
@@ -225,7 +225,8 @@ impl DevelopSession {
|
||||
///
|
||||
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
|
||||
/// rectangle cannot express that — the straightening angle is handled
|
||||
/// alongside this, but a quarter-turned frame shows the overlay unturned.
|
||||
/// alongside this, but a quarter-turned frame shows the overlay unturned,
|
||||
/// and a keystoned one (FR-DEV-20) shows it unwarped.
|
||||
/// Fixing it properly means running the overlay through the same shader
|
||||
/// prologue the image goes through, which is the right answer and a larger
|
||||
/// one than this.
|
||||
|
||||
@@ -1196,6 +1196,56 @@ fn wire_rotation_flips_straighten(
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// TRACES: FR-DEV-20
|
||||
// The keystone sliders: applied on every frame of the drag, like the
|
||||
// angle, so the photograph follows the thumb.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window
|
||||
.global::<Framing>()
|
||||
.on_keystone_v_changed(move |amount| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_keystone_v(amount);
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window
|
||||
.global::<Framing>()
|
||||
.on_keystone_h_changed(move |amount| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_keystone_h(amount);
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// TRACES: FR-DEV-20
|
||||
// The end of a keystone gesture refits the crop, on release only, for
|
||||
// the reasons the straightening's does above: a straightened frame's
|
||||
// empty corners move with the keystone.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.global::<Framing>().on_keystone_committed(move || {
|
||||
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
|
||||
|
||||
@@ -274,6 +274,12 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
||||
"param.crop_y" => "Crop Top",
|
||||
"param.crop_w" => "Crop Width",
|
||||
"param.crop_h" => "Crop Height",
|
||||
// TRACES: FR-DEV-20
|
||||
// The panel labels these "Vertical" and "Horizontal" under the
|
||||
// straightening, where the context says what they correct; a history
|
||||
// row has no such context, so it says it.
|
||||
"param.keystone_v" => "Vertical Perspective",
|
||||
"param.keystone_h" => "Horizontal Perspective",
|
||||
|
||||
// TRACES: FR-DEV-5
|
||||
// The steps that are not a parameter moving.
|
||||
|
||||
+7
-2
@@ -416,6 +416,9 @@ fn reset_view_state(window: &AppWindow) {
|
||||
let framing = window.global::<Framing>();
|
||||
framing.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
|
||||
framing.set_angle(0.0);
|
||||
framing.set_max_keystone(dr_pipeline::framing::MAX_KEYSTONE);
|
||||
framing.set_keystone_v(0.0);
|
||||
framing.set_keystone_h(0.0);
|
||||
framing.set_flip_h(false);
|
||||
framing.set_flip_v(false);
|
||||
framing.set_modified(false);
|
||||
@@ -550,13 +553,15 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
||||
let Some(s) = session.borrow().as_ref().map(|s| {
|
||||
let (h, v) = s.flips();
|
||||
let c = s.crop();
|
||||
(s.angle(), h, v, s.framing_edits_image(), c)
|
||||
(s.angle(), s.keystone(), h, v, s.framing_edits_image(), c)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let (angle, flip_h, flip_v, modified, crop) = s;
|
||||
let (angle, (keystone_v, keystone_h), flip_h, flip_v, modified, crop) = s;
|
||||
let framing = window.global::<Framing>();
|
||||
framing.set_angle(angle);
|
||||
framing.set_keystone_v(keystone_v);
|
||||
framing.set_keystone_h(keystone_h);
|
||||
framing.set_flip_h(flip_h);
|
||||
framing.set_flip_v(flip_v);
|
||||
framing.set_modified(modified);
|
||||
|
||||
Reference in New Issue
Block a user