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:
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user