From 050dcff5bb4e1a2ace7f45a0187a21602971cc7f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 20:55:57 +0200 Subject: [PATCH] Add viewport zoom to the framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/dr-pipeline/src/framing.rs | 179 +++++++++++++++++++++++++++++++- core/dr-pipeline/src/graph.rs | 6 +- 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index 576d1d0..517a643 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -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] diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 2bdf883..523f906 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -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.