Let a zoomed view fill the viewport rather than keep the photograph's shape

The view was the same fraction of each axis, so it kept the frame's
aspect at every zoom: a portrait zoomed on a landscape screen stayed a
portrait strip with the screen's sides empty. Each axis now shows as
much of the frame as the viewport holds at that magnification, capped
at the whole frame, and the render is fitted to the viewed region
rather than to the frame. A redraw re-cuts a zoomed view about its
centre when the viewport or the crop changes shape.
This commit is contained in:
2026-10-02 22:29:48 -04:00
parent d5c93ae795
commit 0b06e31bf3
6 changed files with 239 additions and 71 deletions
File diff suppressed because one or more lines are too long
+180 -30
View File
@@ -523,12 +523,18 @@ impl DevelopSession {
}
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
///
/// Read off the *shorter* extent of the view. The view takes the
/// viewport's shape (see [`view_extents`]), so the axis that limited the
/// fit is the one that shrinks as 1/zoom, while the other may still be
/// showing the whole frame across.
pub fn zoom(&self) -> f32 {
let v = self.graph.framing().view();
if v.width <= 0.0 {
let shorter = v.width.min(v.height);
if shorter <= 0.0 {
1.0
} else {
1.0 / v.width
1.0 / shorter
}
}
@@ -543,16 +549,22 @@ impl DevelopSession {
/// magnifying the photograph rather than sliding it around.
///
/// `factor` multiplies the current zoom — above 1 moves in.
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
///
/// `viewport_w`/`viewport_h` give the view its shape; see
/// [`view_extents`]. Only their ratio matters, so a draft frame's halved
/// viewport would do as well as the full one.
pub fn zoom_about(
&mut self,
factor: f32,
at_x: f32,
at_y: f32,
viewport_w: u32,
viewport_h: u32,
) {
const MAX_ZOOM: f32 = 16.0;
let view = self.graph.framing().view();
let current = if view.width > 0.0 {
1.0 / view.width
} else {
1.0
};
let target = (current * factor).clamp(1.0, MAX_ZOOM);
let target = (self.zoom() * factor).clamp(1.0, MAX_ZOOM);
// Snapped so scrolling back out reliably reaches "fit" rather than
// stopping a fraction short and leaving the image imperceptibly
// panned.
@@ -562,7 +574,7 @@ impl DevelopSession {
target
};
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
let (ew, eh) = self.view_extents(target, viewport_w, viewport_h);
// The point under the cursor, in framed coordinates, must land back
// under the cursor afterwards.
@@ -570,19 +582,53 @@ impl DevelopSession {
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.set_view_clamped(
anchor_x - at_x.clamp(0.0, 1.0) * extent,
anchor_y - at_y.clamp(0.0, 1.0) * extent,
extent,
anchor_x - at_x.clamp(0.0, 1.0) * ew,
anchor_y - at_y.clamp(0.0, 1.0) * eh,
(ew, eh),
);
}
/// TRACES: FR-UI-4
/// Keep a zoomed view the shape of the viewport it is drawn in.
///
/// The view is shaped when it is zoomed, but the viewport can change under
/// it — a window resized, a panel opened, a crop that changes the frame's
/// aspect — and a view left in the old shape letterboxes again. Re-cut
/// about its centre at the same zoom; a fitted view is left alone, since
/// fitting is the whole frame whatever the box.
///
/// Free when nothing moved: the view is only written when its shape is
/// out by more than float noise, so a redraw per frame does not churn it.
pub fn shape_view_to(&mut self, viewport_w: u32, viewport_h: u32) {
if !self.is_zoomed() {
return;
}
let view = self.graph.framing().view();
let (ew, eh) = self.view_extents(self.zoom(), viewport_w, viewport_h);
if (ew - view.width).abs() < 1e-4 && (eh - view.height).abs() < 1e-4 {
return;
}
let (cx, cy) = self.inspection_point();
self.set_view_clamped(cx - ew / 2.0, cy - eh / 2.0, (ew, eh));
}
/// The view's extents at `zoom` in this viewport; see [`view_extents`].
fn view_extents(&self, zoom: f32, viewport_w: u32, viewport_h: u32) -> (f32, f32) {
let (sw, sh) = self.demosaiced.size();
view_extents(
self.graph.output_size(sw, sh),
zoom,
(viewport_w, viewport_h),
)
}
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(
view.x + dx * view.width,
view.y + dy * view.height,
view.width,
(view.width, view.height),
);
}
@@ -636,9 +682,9 @@ impl DevelopSession {
/// and this is the next photograph arriving under the magnifier the last
/// one was left under.
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
let extent =
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
let zoom = self.one_to_one_zoom(viewport_w, viewport_h);
let (ew, eh) = self.view_extents(zoom, viewport_w, viewport_h);
self.set_view_clamped(x - ew / 2.0, y - eh / 2.0, (ew, eh));
}
/// TRACES: FR-UI-4 | FR-DEV-3
@@ -689,19 +735,19 @@ impl DevelopSession {
Some(self.inspection_point())
}
/// Place a square view of `extent`, keeping it inside the frame.
/// Place a view of `extent` (width, height), keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
/// boundary would show undefined area beside the photograph, which reads
/// as a rendering fault rather than as the end of the image.
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
let max = 1.0 - extent;
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: (f32, f32)) {
let ew = extent.0.clamp(CropRect::MIN_EXTENT, 1.0);
let eh = extent.1.clamp(CropRect::MIN_EXTENT, 1.0);
self.graph.framing_mut().set_view(CropRect {
x: x.clamp(0.0, max.max(0.0)),
y: y.clamp(0.0, max.max(0.0)),
width: extent,
height: extent,
x: x.clamp(0.0, (1.0 - ew).max(0.0)),
y: y.clamp(0.0, (1.0 - eh).max(0.0)),
width: ew,
height: eh,
});
}
@@ -714,6 +760,35 @@ impl DevelopSession {
}
}
/// TRACES: FR-UI-4
/// The view at `zoom`, as fractions of the `framed` image, shaped to fill
/// `viewport`.
///
/// **The view takes the viewport's shape, not the photograph's.** It used to
/// be the same fraction of each axis, so it kept the frame's aspect at every
/// zoom: a portrait photograph zoomed in on a landscape screen stayed a
/// portrait strip with the screen's sides empty, showing less of the frame
/// than the screen had room for. Here each axis shows as much of the frame
/// as the viewport holds at this magnification, capped at the whole frame —
/// so zooming a portrait widens it until it meets the screen's sides, and
/// from there both axes close in together.
///
/// `zoom` is relative to fit, as [`DevelopSession::zoom`] reports it: the
/// axis that limited the fit is the one that shows exactly `1/zoom`. Only
/// the viewport's ratio matters, not its size.
pub(super) fn view_extents(framed: (u32, u32), zoom: f32, viewport: (u32, u32)) -> (f32, f32) {
if zoom <= 1.0 {
return (1.0, 1.0);
}
let (fw, fh) = (framed.0.max(1) as f32, framed.1.max(1) as f32);
let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
// Screen pixels per framed pixel at this zoom.
let scale = zoom * (vw / fw).min(vh / fh);
let extent =
|screen: f32, frame: f32| (screen / (frame * scale)).clamp(CropRect::MIN_EXTENT, 1.0);
(extent(vw, fw), extent(vh, fh))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -856,7 +931,7 @@ mod tests {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.zoom_about(3.0, 0.5, 0.5);
session.zoom_about(3.0, 0.5, 0.5, 64, 64);
assert!(session.is_zoomed(), "the premise");
assert_eq!(
@@ -919,7 +994,7 @@ mod tests {
.expect("session");
let fitted = session.render(64, 64).expect("fitted render");
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
assert!(session.is_zoomed(), "the session did not register the zoom");
let zoomed = session.render(64, 64).expect("zoomed render");
@@ -963,12 +1038,12 @@ mod tests {
!session.magnifies_source(200, 200),
"a downscaled image is not magnified"
);
session.zoom_about(2.0, 0.5, 0.5);
session.zoom_about(2.0, 0.5, 0.5, 64, 64);
assert!(
!session.magnifies_source(200, 200),
"2x on a 4x-downscaled source is still below 1:1"
);
session.zoom_about(8.0, 0.5, 0.5);
session.zoom_about(8.0, 0.5, 0.5, 64, 64);
assert!(
session.magnifies_source(200, 200),
"16x on a 4x-downscaled source magnifies and must not be filtered"
@@ -1022,7 +1097,7 @@ mod tests {
// At 4× only sixteen are behind it, and sixteen are what is rendered.
session.reset_zoom();
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let magnified = session.render(32, 32).expect("magnified render");
assert_eq!(
(magnified.size().width, magnified.size().height),
@@ -1032,6 +1107,81 @@ mod tests {
);
}
/// TRACES: FR-UI-4
/// A zoomed view takes the viewport's shape, capped at the whole frame.
///
/// A 2:3 portrait on a 16:9 screen: at 2× its width still fits across, so
/// the view is the full width and half the height; by 4× the width no
/// longer fits and the view is a 16:9 window onto the frame.
#[test]
fn a_zoomed_view_is_the_shape_of_the_viewport() {
let (framed, viewport) = ((2000, 3000), (1600, 900));
assert_eq!(view_extents(framed, 1.0, viewport), (1.0, 1.0));
let (w, h) = view_extents(framed, 2.0, viewport);
assert_eq!(
w, 1.0,
"at 2x a portrait's whole width fits on a landscape screen"
);
assert!(
(h - 0.5).abs() < 1e-6,
"the limiting axis shows 1/zoom, got {h}"
);
let (w, h) = view_extents(framed, 4.0, viewport);
assert!((h - 0.25).abs() < 1e-6);
let shown = (2000.0 * w) / (3000.0 * h);
assert!(
(shown - 1600.0 / 900.0).abs() < 1e-3,
"at 4x the view should be 16:9, got {shown}"
);
}
/// TRACES: FR-UI-4
/// Zooming a portrait photograph on a landscape canvas renders in the
/// canvas's shape rather than letterboxing.
///
/// The fault this guards: the view kept the photograph's aspect at every
/// zoom, so the render stayed a portrait strip and the sides of the screen
/// stayed empty however far in the photographer went.
#[test]
fn zooming_a_portrait_fills_a_landscape_viewport() {
let Some(ctx) = headless() else { return };
let (w, h) = (40u32, 60u32);
let rgba = vec![128u8; (w * h * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
.expect("session");
let (vw, vh) = (64u32, 36u32);
let fitted = session.render(vw, vh).expect("fitted render");
assert!(
fitted.size().width < fitted.size().height,
"the premise: fitted, a portrait is a portrait"
);
session.zoom_about(4.0, 0.5, 0.5, vw, vh);
assert!(
(session.zoom() - 4.0).abs() < 1e-3,
"zoom is {}",
session.zoom()
);
let zoomed = session.render(vw, vh).expect("zoomed render");
let shown = zoomed.size().width as f32 / zoomed.size().height as f32;
assert!(
(shown - vw as f32 / vh as f32).abs() < 0.1,
"a 4x view of a portrait on a 16:9 canvas should be 16:9, got {}x{}",
zoomed.size().width,
zoomed.size().height
);
// A canvas reshaped under a zoomed view re-cuts it, at the same zoom.
session.shape_view_to(36, 64);
assert!((session.zoom() - 4.0).abs() < 1e-3);
let turned = session.render(36, 64).expect("reshaped render");
assert!(turned.size().width < turned.size().height);
}
/// TRACES: FR-DEV-20
/// The keystone sliders are edits with a history, a reset, and a crop
/// that follows them.
+2 -2
View File
@@ -860,7 +860,7 @@ mod tests {
};
let (_, _, full_w, full_h) = session.overlay_clip();
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
assert!(
@@ -878,7 +878,7 @@ mod tests {
return;
};
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let (before_x, _, _, _) = session.overlay_clip();
session.pan_by(0.3, 0.0);
let (after_x, _, _, _) = session.overlay_clip();
+12 -5
View File
@@ -108,21 +108,28 @@ pub(super) fn shows_source_pixels(magnification: f64) -> bool {
/// pixels an export would have, and leaves the enlargement to it, which draws
/// them nearest-neighbour; it is also a fraction of the shading.
///
/// Below 1:1 this is [`fit`] of the whole frame, as it always was: the view
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
/// gains detail. The two branches meet at 1:1, where both are the viewport.
/// Below 1:1 this is [`fit`] of the *viewed region*: the view rect shrinking
/// while the target keeps its size is how a zoom short of 1:1 gains detail.
/// The region rather than the whole frame, because a zoomed view takes the
/// viewport's shape (see `framing::view_extents`), and a render in the
/// frame's shape would letterbox it straight back. The two branches meet at
/// 1:1, where both are the viewport.
pub(super) fn render_size(
framed: (u32, u32),
view: (f32, f32),
viewport: (u32, u32),
) -> (u32, u32) {
if framed.0 == 0 || framed.1 == 0 || magnification(framed, view, viewport) < 1.0 {
if framed.0 == 0 || framed.1 == 0 {
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
}
let behind = |edge: u32, fraction: f32| {
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
};
(behind(framed.0, view.0), behind(framed.1, view.1))
let region = (behind(framed.0, view.0), behind(framed.1, view.1));
if magnification(framed, view, viewport) < 1.0 {
return fit(region.0, region.1, viewport.0.max(1), viewport.1.max(1));
}
region
}
impl DevelopSession {
+3 -1
View File
@@ -895,10 +895,12 @@ fn wire_zoom_pan_crop(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let viewport = viewport.clone();
window.on_zoom_at(move |factor, at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
let (vw, vh) = *viewport.borrow();
if let Some(s) = session.borrow_mut().as_mut() {
s.zoom_about(factor, at_x, at_y);
s.zoom_about(factor, at_x, at_y, vw, vh);
}
redraw(&w);
});
+9
View File
@@ -2180,6 +2180,15 @@ fn build_render_now(
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
}
// TRACES: FR-UI-4
// A zoomed view keeps the viewport's shape through a resize or a
// crop that changes the frame's. First, because the overlays
// below are placed against the view this may re-cut.
{
let (vw, vh) = *viewport.borrow();
s.shape_view_to(vw, vh);
}
// TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here
// rather than in the panel's own sync because a pan or a zoom