Add viewport zoom to the framing

A view rect that composes with the crop in the same normalised space:
nesting one rect in the other is a multiply, so the shader needs no second
rect and no extra uniform slot.

Zoom is explicitly not an edit. It is excluded from is_active, from the
structure hash, and from the sidecar, so a zoomed view exports exactly as an
unzoomed one does. is_neutral now asks the framing whether it *edits* rather
than whether it is active — otherwise merely zooming would mark a clean file
dirty.

Because the render target keeps its size while the sampled region shrinks,
zooming raises the resolution the pipeline works at rather than magnifying
already-rendered pixels, which is what makes 1:1 inspection show real detail.

Assisted-by: LLM
This commit is contained in:
2026-08-09 20:55:57 +02:00
parent 81e89de7ea
commit 050dcff5bb
2 changed files with 180 additions and 5 deletions
+175 -4
View File
@@ -186,6 +186,18 @@ pub struct Framing {
flip_h: bool,
flip_v: bool,
crop: CropRect,
/// Which part of the framed image the viewport is looking at.
///
/// **Not an edit.** Zooming changes what you are inspecting, never what
/// the file becomes: it is excluded from [`Self::is_active`], from the
/// structure hash, and from the sidecar, so a zoomed view exports exactly
/// as an unzoomed one does.
///
/// It lives here rather than in the UI because it composes with the crop
/// in the same normalised space — nesting one rect inside the other is a
/// multiply, and doing it here means the shader needs no second rect and
/// no extra uniform slot.
view: CropRect,
}
impl Default for Framing {
@@ -196,6 +208,7 @@ impl Default for Framing {
flip_h: false,
flip_v: false,
crop: CropRect::default(),
view: CropRect::default(),
}
}
}
@@ -222,6 +235,27 @@ impl Framing {
self.crop = rect.normalised();
}
/// The region of the framed image the viewport shows.
pub fn view(&self) -> CropRect {
self.view
}
/// Look at part of the framed image, in fractions of it.
///
/// The whole unit square is "fit to the viewport"; a smaller rect is
/// zoomed in. Because the render target keeps its size while the sampled
/// region shrinks, zooming *raises* the resolution the pipeline works at
/// rather than magnifying already-rendered pixels — which is what makes a
/// 1:1 inspection show real detail.
pub fn set_view(&mut self, rect: CropRect) {
self.view = rect.normalised();
}
/// Whether the viewport is showing anything other than the whole frame.
pub fn is_zoomed(&self) -> bool {
!self.view.is_full()
}
pub fn angle(&self) -> f32 {
self.angle
}
@@ -245,6 +279,26 @@ impl Framing {
/// nothing to the generated shader, so an uncropped image reads its
/// pixels through the identity map exactly as it did before this existed.
pub fn is_active(&self) -> bool {
self.angle != 0.0
|| self.quarter_turns != 0
|| self.flip_h
|| self.flip_v
|| !self.crop.is_full()
// Zoom is not an edit, but it *is* a coordinate map: without the
// prologue the shader samples the whole frame and the zoom does
// nothing. It is excluded from `structure_key` instead, so
// zooming re-uploads uniforms rather than recompiling.
|| self.is_zoomed()
}
/// Whether this stage changes the *image*, as opposed to merely what is
/// on screen.
///
/// [`Self::is_active`] answers "must the prologue be emitted", which zoom
/// also requires. This answers "would the exported file differ", which
/// zoom must never affect — it is what tells the interface whether there
/// are edits worth saving.
pub fn edits_image(&self) -> bool {
self.angle != 0.0
|| self.quarter_turns != 0
|| self.flip_h
@@ -349,6 +403,19 @@ impl Framing {
(cw, ch)
}
/// The output size ignoring the crop — the whole frame, turned.
///
/// What the crop overlay measures against: it draws the rect the user is
/// selecting, so it needs the shape being selected *from*, not the shape
/// the crop currently produces.
pub fn output_size_uncropped(&self, width: u32, height: u32) -> (u32, u32) {
if self.swaps_axes() {
(height.max(1), width.max(1))
} else {
(width.max(1), height.max(1))
}
}
/// The largest centred crop, at the current angle, containing no
/// undefined area.
///
@@ -412,11 +479,17 @@ impl Framing {
/// call per pixel would recover a value constant across the dispatch.
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
let rad = self.angle * PI / 180.0;
// The view nests *inside* the crop: the prologue applies one rect,
// and two nested rects in the same normalised space compose into one.
// Doing it here rather than in the shader keeps the per-pixel work
// identical whether or not the user is zoomed in, and costs no extra
// uniform slot.
let rect = self.visible_rect();
[
self.crop.x,
self.crop.y,
self.crop.width,
self.crop.height,
rect.x,
rect.y,
rect.width,
rect.height,
rad.sin(),
rad.cos(),
0.0,
@@ -424,6 +497,19 @@ impl Framing {
]
}
/// The crop and the view composed into the single rect the shader samples.
///
/// Separate from [`Self::uniforms`] so the composition can be tested as
/// the piece of geometry it is, rather than through a uniform array.
pub fn visible_rect(&self) -> CropRect {
CropRect {
x: self.crop.x + self.view.x * self.crop.width,
y: self.crop.y + self.view.y * self.crop.height,
width: self.crop.width * self.view.width,
height: self.crop.height * self.view.height,
}
}
/// The WGSL mapping an output pixel to a **normalised centred** source
/// position, ready for the warp chain.
///
@@ -547,6 +633,91 @@ mod tests {
assert!(!f.is_active());
assert!(!f.needs_interpolation());
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
assert!(!f.is_zoomed());
}
#[test]
fn zooming_does_not_change_the_exported_image() {
// The property that makes zoom a viewing tool rather than an edit: it
// must not reach the output size, the structure hash, or the crop.
// If it did, exporting while zoomed would write the zoomed view.
let mut f = Framing::new();
let before_size = f.output_size(6000, 4000);
let before_key = f.structure_key();
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
assert_eq!(f.output_size(6000, 4000), before_size, "zoom resized output");
assert_eq!(f.structure_key(), before_key, "zoom forced a recompile");
assert!(f.crop().is_full(), "zoom altered the crop");
}
#[test]
fn the_view_nests_inside_the_crop() {
// Both rects live in the same normalised space and the shader applies
// only one, so they must compose. Getting this wrong would make
// zooming inside a crop jump to a different part of the photograph.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 0.5,
});
// The centre quarter *of the crop*.
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let r = f.visible_rect();
// Origin: a quarter into a crop that starts at 0.5 and spans 0.5.
assert!((r.x - 0.625).abs() < 1e-6, "x was {}", r.x);
assert!((r.y - 0.125).abs() < 1e-6, "y was {}", r.y);
// Extent: half of half.
assert!((r.width - 0.25).abs() < 1e-6, "width was {}", r.width);
assert!((r.height - 0.25).abs() < 1e-6, "height was {}", r.height);
}
#[test]
fn a_full_view_leaves_the_crop_exactly_as_it_was() {
// Composition must be an identity when unzoomed, or merely opening an
// image would shift the crop by a rounding error.
let mut f = Framing::new();
let crop = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
f.set_crop(crop);
let r = f.visible_rect();
assert!((r.x - crop.x).abs() < 1e-6);
assert!((r.y - crop.y).abs() < 1e-6);
assert!((r.width - crop.width).abs() < 1e-6);
assert!((r.height - crop.height).abs() < 1e-6);
}
#[test]
fn a_zoomed_framing_emits_the_coordinate_map() {
// Zoom is excluded from the structure hash but must still make the
// prologue active — otherwise the shader samples the whole frame and
// the zoom silently does nothing.
let mut f = Framing::new();
f.set_view(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(f.is_active(), "a zoomed view must emit the crop mapping");
}
#[test]
+5 -1
View File
@@ -269,8 +269,12 @@ impl EditGraph {
}
/// Whether any operation, or the framing, currently changes the image.
///
/// Asks the framing whether it *edits*, not whether it is active: zoom
/// makes the framing active without changing the image, and reporting a
/// merely-zoomed image as edited would mark a clean file dirty.
pub fn is_neutral(&self) -> bool {
!self.ops.iter().any(|o| o.is_active()) && !self.framing.is_active()
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
}
/// Generate the fused shader for the current state.