//! Framing — crop, straighten, rotate, flip (FR-DEV-3, ARCH §5.2). //! //! # Why this is not an `Operation`, and not a `Warp` either //! //! An [`crate::operation::Operation`] is a function from colour to colour. By //! the time one runs, the colour has been sampled and the question framing //! asks — *which* source pixel does this output pixel come from — has already //! been answered. And a crop changes the output's dimensions and aspect //! ratio, which no colour fragment can express. //! //! [`crate::lens::Warp`] is closer: it also rewrites coordinates before the //! fetch. But a warp is a *correction to the optics* — distortion and CA are //! properties of the lens, defined about the optical axis, over the whole //! frame the lens projected. Framing is a decision about *composition*, made //! afterwards. The order matters and is not a preference: //! //! ```text //! output pixel → framing → warp (lens) → sample → colour ops → output //! ``` //! //! Reading forward, the lens is corrected on the full frame and the crop //! then selects from the corrected result. Correcting distortion on an //! already-cropped frame would place the optical centre in the wrong spot and //! bend the image about a point the lens never saw. //! //! So framing runs **first** in the coordinate chain, and hands the warp //! chain exactly the space it documents: normalised, centred, `r == 1` at the //! corner. Neither stage needs to know the other exists. //! //! # Why sampling changes with the angle //! //! At 90° steps and flips, output pixels land exactly on source pixels, so //! the map is a permutation and an integer `textureLoad` is both correct and //! lossless. At any other angle it is not, and nearest-neighbour sampling //! makes a straightened horizon visibly stair-step — the commonest use of //! this stage, and where the artefact is most obvious. Free angles therefore //! need interpolation, which is what [`Framing::needs_interpolation`] tells //! the composer. Paying for it only when the angle demands it keeps the //! common case exact rather than merely close. use std::f32::consts::PI; use std::fmt::Write as _; use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit}; use crate::operation::Affects; pub const ID: OpId = OpId("framing"); pub const ANGLE: ParamId = ParamId("angle"); pub const ROTATION: ParamId = ParamId("rotation"); pub const FLIP_H: ParamId = ParamId("flip_h"); pub const FLIP_V: ParamId = ParamId("flip_v"); pub const CROP_X: ParamId = ParamId("crop_x"); pub const CROP_Y: ParamId = ParamId("crop_y"); pub const CROP_W: ParamId = ParamId("crop_w"); pub const CROP_H: ParamId = ParamId("crop_h"); /// Widest straightening the control offers, in degrees either way. /// /// Straightening a horizon is a small correction; a gross reorientation is /// what the 90° steps are for. Bounding it keeps the slider's travel where /// the edits actually are. pub const MAX_STRAIGHTEN: f32 = 45.0; static DESCRIPTOR: OpDescriptor = OpDescriptor { id: ID, label: LocalizedKey("op.framing"), params: &[ // Straightening. Degrees rather than a normalised amount because a // photographer reading "-1.4°" off a horizon knows what it means. ParamDescriptor::scalar( "angle", "param.angle", -MAX_STRAIGHTEN, MAX_STRAIGHTEN, 0.0, Unit::None, Scale::Linear, 2, ), // Quarter turns, 0..3. Separate from `angle` because these are exact // and lossless, and because reorienting a frame is a different // gesture from nudging a horizon. ParamDescriptor::scalar( "rotation", "param.rotation", 0.0, 3.0, 0.0, Unit::None, Scale::Linear, 0, ), ParamDescriptor::switch("flip_h", "param.flip_h"), ParamDescriptor::switch("flip_v", "param.flip_v"), // The crop rect, in fractions of the source. Normalised rather than // in pixels so a crop survives being applied to a proxy, a full // resolution render, or an export at another size — the same reason // the viewport renders at display resolution (FR-DSP-1). ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0), ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0), ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0), ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0), ], }; /// A normalised crop rectangle, in fractions of the source image. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CropRect { pub x: f32, pub y: f32, pub width: f32, pub height: f32, } impl Default for CropRect { fn default() -> Self { Self { x: 0.0, y: 0.0, width: 1.0, height: 1.0, } } } impl CropRect { /// Smallest crop the rect may be reduced to, as a fraction of the source. /// /// A zero-extent crop produces a zero-sized output texture, which is a /// device error rather than a visibly silly image. Bounding it here means /// no caller has to defend against it. pub const MIN_EXTENT: f32 = 0.01; /// Whether this rect selects the whole image. pub fn is_full(&self) -> bool { self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0 } /// Clamp into the unit square, keeping the rect non-degenerate. /// /// The origin is clamped first and the extent fitted to what remains, so /// a rect dragged past an edge slides rather than inverting. pub fn normalised(self) -> Self { let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT); let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT); Self { x, y, // `max` before `min`, not `f32::clamp`. With the origin at its // limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` — // an inverted range, which `clamp` panics on rather than // resolving. Ordering it this way lets the lower bound win, which // is also the answer that keeps the rect non-degenerate. width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT), height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT), } } } /// Replace a non-finite value with a fallback. /// /// A NaN reaching the crop rect would propagate into the output *dimensions*, /// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a /// device error. The same defence as `ParamDescriptor::clamp`, one level up. fn finite(v: f32, fallback: f32) -> f32 { if v.is_finite() { v } else { fallback } } /// TRACES: FR-DEV-3 | FR-DEV-3d /// Crop, straighten, rotation and flips for one image. /// /// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost /// device is recovered by re-composing rather than by re-deriving the edit /// (ARCH §6.10). #[derive(Debug, Clone, Copy, PartialEq)] pub struct Framing { /// Straightening, in degrees. Positive rotates the image clockwise. angle: f32, /// Quarter turns clockwise, 0..=3. quarter_turns: u8, flip_h: bool, flip_v: bool, /// TRACES: FR-DEV-3h /// How the file's pixels were stored, from its EXIF orientation. /// /// **Not an edit**, and this is the whole reason it is a separate field /// rather than a starting value for `quarter_turns`. A camera held /// sideways stored its rows the way it always does and wrote a tag saying /// so; obeying that tag is part of reading the file, not a decision the /// user made. Folding it into `quarter_turns` would make every portrait /// frame open already-modified, write a rotation into every sidecar, and — /// worst — make "reset framing" lay the photograph on its side, since /// reset's whole meaning is "back to the file as it is". /// /// So it sits underneath: [`Self::param`] and the sidecar see only the /// user's turns, while everything that renders or measures the frame sees /// the two composed. It composes exactly, because a quarter turn and two /// mirrors form a group of eight that is closed under composition — the /// pair always collapses back to one turn and two flags, so the shader /// still emits a single permutation and costs nothing for the baseline. baseline: dr_types::Orientation, 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 { fn default() -> Self { Self { angle: 0.0, quarter_turns: 0, flip_h: false, flip_v: false, baseline: dr_types::Orientation::NORMAL, crop: CropRect::default(), view: CropRect::default(), } } } impl Framing { pub fn new() -> Self { Self::default() } pub fn descriptor(&self) -> &'static OpDescriptor { &DESCRIPTOR } /// What this stage affects, for invalidation scoping (FR-DEV-3d). pub fn affects(&self) -> Affects { Affects::Geometry } pub fn crop(&self) -> CropRect { self.crop } pub fn set_crop(&mut self, rect: CropRect) { 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 } pub fn quarter_turns(&self) -> u8 { self.quarter_turns } pub fn flips(&self) -> (bool, bool) { (self.flip_h, self.flip_v) } /// Add quarter turns, wrapping. The rotate-left/right buttons. pub fn rotate_quarters(&mut self, turns: i32) { self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8; } /// How the file stored its pixels — see the field. pub fn baseline(&self) -> dr_types::Orientation { self.baseline } /// Record the file's EXIF orientation. /// /// Set once when the image is opened, before any edit is restored. It is /// deliberately not a `set_param`: the descriptor lists what the user can /// change, and this is a property of the file. pub fn set_baseline(&mut self, orientation: dr_types::Orientation) { self.baseline = orientation; } /// The baseline and the user's turns and mirrors, collapsed into one. /// /// Everything that renders or measures the frame goes through here; only /// the panel's readout and the sidecar read the user's values raw. /// /// The composition is the group law, not an addition. Writing a transform /// as `mirrors ∘ turn`, applying the user's and then the baseline's gives /// `Db · Rtb · Du · Rtu`, and conjugating `Du` past `Rtb` swaps its two /// axes when that turn is odd — which is exactly the case that a naive /// "add the turns, or the flags" gets wrong, and gets wrong silently, /// since the result is still a valid-looking orientation. fn effective(&self) -> (u8, bool, bool) { let b = self.baseline; // The user's mirrors, seen from the far side of the baseline's turn. let (ux, uy) = if b.swaps_axes() { (self.flip_v, self.flip_h) } else { (self.flip_h, self.flip_v) }; ( (b.quarter_turns + self.quarter_turns) % 4, b.flip_h != ux, b.flip_v != uy, ) } /// Whether this stage currently changes the image. /// /// The same contract the operations honour: neutral framing contributes /// 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 { let (turns, flip_h, flip_v) = self.effective(); self.angle != 0.0 // Effective, not the user's: a file stored sideways needs the // prologue emitted even on an untouched image, or it renders // through the identity map and lies on its side. || turns != 0 || flip_h || 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. /// /// The baseline is excluded on purpose. Opening a portrait frame that the /// camera stored sideways must not light the modified dot, enable the /// reset, or persuade the sidecar there is something to save — nothing was /// edited, the file was merely read correctly. pub fn edits_image(&self) -> bool { self.angle != 0.0 || self.quarter_turns != 0 || self.flip_h || self.flip_v || !self.crop.is_full() } /// Whether the axes are swapped — a 90° or 270° turn, baseline included. fn swaps_axes(&self) -> bool { self.effective().0 % 2 == 1 } /// Whether the map puts output pixels between source pixels. /// /// False for quarter turns and flips, which are permutations with an /// exact answer. True once a free angle is involved. The composer reads /// this to decide between an integer load and a filtered sample — and a /// warp being active forces interpolation regardless, which is the /// composer's call to make rather than this stage's. pub fn needs_interpolation(&self) -> bool { self.angle != 0.0 } pub fn set_param(&mut self, id: ParamId, value: f32) { match id { ANGLE => self.angle = finite(value, 0.0), // Descriptor-clamped to 0..3, so the cast cannot wrap. ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8, FLIP_H => self.flip_h = value != 0.0, FLIP_V => self.flip_v = value != 0.0, CROP_X => { self.crop = CropRect { x: value, ..self.crop } .normalised() } CROP_Y => { self.crop = CropRect { y: value, ..self.crop } .normalised() } CROP_W => { self.crop = CropRect { width: value, ..self.crop } .normalised() } CROP_H => { self.crop = CropRect { height: value, ..self.crop } .normalised() } _ => log::warn!("framing: unknown parameter {id}"), } } pub fn param(&self, id: ParamId) -> f32 { match id { ANGLE => self.angle, ROTATION => f32::from(self.quarter_turns), FLIP_H => f32::from(u8::from(self.flip_h)), FLIP_V => f32::from(u8::from(self.flip_v)), CROP_X => self.crop.x, CROP_Y => self.crop.y, CROP_W => self.crop.width, CROP_H => self.crop.height, _ => 0.0, } } /// Clear every framing edit. /// /// The baseline survives, because it was never an edit. "Reset" means /// *the file as it is*, and the file is upright — so this returns the /// photograph to how the camera meant it to be seen rather than to how /// the sensor happened to be scanned. pub fn reset(&mut self) { *self = Self { baseline: self.baseline, ..Self::default() }; } /// The output size this framing produces from a source of `(w, h)`. /// /// The rendered aspect ratio follows from here, which is why this is the /// one piece of framing both the UI and the GPU pass need before any /// pixel is shaded: the output texture is allocated from it. /// /// A free angle does **not** change the output size. The rotated image is /// sampled into the crop rect as it stands, so straightening a horizon /// leaves the frame where the user put it and may pull in undefined area /// at the corners — see [`Self::max_inscribed_crop`] for the rect that /// avoids that. pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) { let (w, h) = if self.swaps_axes() { (height, width) } else { (width, height) }; // Round rather than truncate: half of a 101px axis should be 51, and // truncation biases every crop smaller. let cw = ((w as f32 * self.crop.width).round() as u32).max(1); let ch = ((h as f32 * self.crop.height).round() as u32).max(1); (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. /// /// Rotating a rectangle inside its own bounds exposes the corners: there /// is no source pixel there, and the shader renders it black. This is the /// rect that avoids it — what a "straighten and auto-crop" gesture would /// apply, and what the crop overlay should offer as its bound. /// /// The standard largest-inscribed-rectangle result for a rotated /// rectangle of the same aspect ratio. pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect { if self.angle == 0.0 || width == 0 || height == 0 { return CropRect::default(); } let (w, h) = if self.swaps_axes() { (height as f32, width as f32) } else { (width as f32, height as f32) }; let a = (self.angle * PI / 180.0).abs(); let (sin, cos) = (a.sin(), a.cos()); // Longer and shorter side, so the two cases below stay symmetric. let (long, short) = if w >= h { (w, h) } else { (h, w) }; let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 { // Half-constrained: the shorter side alone limits the rectangle. let half = 0.5 * short; if w >= h { (half / sin, half / cos) } else { (half / cos, half / sin) } } else { // Fully constrained by both sides. let cos2 = cos * cos - sin * sin; ((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2) }; // Back to fractions of the (possibly axis-swapped) frame, centred. let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0); let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0); CropRect { x: (1.0 - fw) * 0.5, y: (1.0 - fh) * 0.5, width: fw, height: fh, } .normalised() } /// Uniform values the generated prologue reads. /// /// A fixed-size block in a fixed slot, like the camera matrix: the /// prologue is emitted whether or not any operation is active, so its /// uniforms cannot be positioned by the op loop. /// /// The angle reaches the shader as sin/cos rather than degrees — a trig /// 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(); [ rect.x, rect.y, rect.width, rect.height, rad.sin(), rad.cos(), 0.0, 0.0, ] } /// 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. /// /// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner — /// exactly the space [`crate::lens`] documents, so lens correction /// composes on top of this without either stage naming the other. /// /// `aspect` is left in scope alongside it, since the warp chain and the /// sampler both need it to return to texture coordinates. pub fn wgsl_prologue(&self) -> String { // Neutral framing still has to produce `p`, since the warp chain and // the sampler read it either way. It emits no `---- ` marker: those // count active stages, and a neutral graph must generate none. if !self.is_active() { return " // Source position, normalised and centred: the whole frame, unrotated. let src_dims = textureDimensions(source); let aspect = vec2(f32(src_dims.x) / f32(src_dims.y), 1.0); let uv = (vec2(gid.xy) + vec2(0.5)) / vec2(dims); var p = (uv - vec2(0.5)) * aspect; " .into(); } let mut s = String::new(); s.push_str( " // ---- framing ---- // Output pixel -> source position, in the normalised centred space the // warp chain expects: the centre is (0, 0) and the radius is 1 at the // corner. Working here rather than in pixels is what makes the map // independent of the resolution being rendered at. let src_dims = textureDimensions(source); let aspect = vec2(f32(src_dims.x) / f32(src_dims.y), 1.0); var uv = (vec2(gid.xy) + vec2(0.5)) / vec2(dims); ", ); s.push_str( " // Into the crop rect. uv = u.crop_rect.xy + uv * u.crop_rect.zw; var p = (uv - vec2(0.5)) * aspect; ", ); if self.angle != 0.0 { // Done in the aspect-corrected space, which is the whole reason // `p` is scaled by `aspect` above: a rotation applied to raw 0..1 // coordinates on a non-square image shears it rather than // turning it, and that reads as a rendering fault. s.push_str( " // Straighten, about the frame centre. p = vec2( p.x * u.framing_angle.y - p.y * u.framing_angle.x, p.x * u.framing_angle.x + p.y * u.framing_angle.y, ); ", ); } // The user's turns and mirrors composed with the file's stored // orientation. One permutation covers both, so honouring the EXIF tag // adds no per-pixel work over an untagged file. let (turns, flip_h, flip_v) = self.effective(); if turns != 0 { // An exact coordinate permutation rather than a rotation through // the matrix above, which would resample a transform that has an // exact answer. Applied to `p`, so the aspect scaling has to be // undone and reapplied across the swap. let permutation = match turns { 1 => " p = vec2(p.y * aspect.x, -p.x / aspect.x);", 2 => " p = -p;", _ => " p = vec2(-p.y * aspect.x, p.x / aspect.x);", }; let _ = write!( s, " // {}° clockwise — an exact permutation, so nothing is resampled. {permutation} ", u32::from(turns) * 90 ); } if flip_h { s.push_str(" p.x = -p.x;\n"); } if flip_v { s.push_str(" p.y = -p.y;\n"); } s } /// Identifies this framing's *structure* — which branches the prologue /// generates, not the values it reads. /// /// Deliberately coarse, for the reason the operation hash is: dragging /// the crop handles or the straighten slider must reuse the compiled /// pipeline and upload uniforms only. Only the presence of each /// transform, never its magnitude, may enter this. /// /// The last bit is *whether the prologue is emitted at all*, which zoom /// reaches through [`Self::is_active`]. It has to be here even though zoom /// is not an edit: the neutral prologue never reads `u.crop_rect`, so a /// pipeline compiled while unzoomed ignores every later view upload. Two /// framings that generate different WGSL must not share a cache key — the /// symptom otherwise is scroll-to-zoom on an otherwise-unedited image /// doing nothing at all, because the first frame compiled the neutral /// prologue and the hash never moved off it. /// /// What this must *not* do is vary with the zoom level: the bit is set by /// any zoom and cleared by none, so a wheel notch is still a uniform /// upload rather than a shader build. pub fn structure_key(&self) -> u64 { // Effective throughout, because this identifies the *generated WGSL* // and that is what the prologue emits. Two images differing only in // their stored orientation must not share a compiled pipeline. let (turns, flip_h, flip_v) = self.effective(); u64::from(!self.crop.is_full()) | u64::from(self.angle != 0.0) << 1 | u64::from(flip_h) << 2 | u64::from(flip_v) << 3 | u64::from(turns) << 4 | u64::from(self.is_active()) << 6 } } /// Floats the framing block occupies in the generated uniform struct. /// /// Two `vec4`s: the crop rect, and the angle's sin/cos with padding. pub const FRAMING_UNIFORM_FIELDS: usize = 8; #[cfg(test)] mod tests { use super::*; use dr_types::Orientation; /// The group law, checked against pixels rather than against itself. /// /// [`Framing::effective`] claims a baseline and a user rotation collapse /// into one turn plus two mirrors. The claim is only worth anything if the /// collapsed transform moves every pixel where the two separate ones /// would, so that is what this asserts, over all 8 × 16 pairs. /// /// The case that fails without the conjugation swap is any odd baseline /// turn combined with a user flip — a phone portrait that the user then /// mirrors. Naive flag-ORing renders it mirrored about the wrong axis, /// which still looks like a photograph. #[test] fn a_baseline_and_a_user_rotation_compose_into_one_permutation() { // Non-square and coprime, so no accidental symmetry hides an error. const SW: u32 = 5; const SH: u32 = 3; for tag in 1..=8u16 { let baseline = Orientation::from_exif(tag); let (ow, oh) = baseline.oriented_size(SW, SH); for user_turns in 0..4u8 { for user_flip_h in [false, true] { for user_flip_v in [false, true] { let user = Orientation { quarter_turns: user_turns, flip_h: user_flip_h, flip_v: user_flip_v, }; let mut f = Framing::new(); f.set_baseline(baseline); f.rotate_quarters(i32::from(user_turns)); f.set_param(FLIP_H, f32::from(u8::from(user_flip_h))); f.set_param(FLIP_V, f32::from(u8::from(user_flip_v))); let (t, fh, fv) = f.effective(); let combined = Orientation { quarter_turns: t, flip_h: fh, flip_v: fv, }; // The output size the composed transform produces must // be the one the two stages produce in sequence. let (dw, dh) = user.oriented_size(ow, oh); assert_eq!( f.output_size(SW, SH), (dw, dh), "tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v}" ); for y in 0..dh { for x in 0..dw { // Display -> oriented -> stored, the long way. let (ox, oy) = user.source_pixel(x, y, dw, dh); let stepwise = baseline.source_pixel(ox, oy, ow, oh); // Display -> stored, in one permutation. let fused = combined.source_pixel(x, y, dw, dh); assert_eq!( stepwise, fused, "tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v} \ at ({x},{y})" ); } } } } } } } #[test] fn a_sideways_file_opens_upright_without_counting_as_an_edit() { // The whole point of the baseline. A phone portrait is 4000x6000 on // screen and 6000x4000 on disk, and none of that is the user's doing: // no modified dot, nothing for the sidecar to save. let mut f = Framing::new(); f.set_baseline(Orientation::from_exif(6)); assert_eq!(f.output_size(6000, 4000), (4000, 6000)); assert_eq!(f.output_size_uncropped(6000, 4000), (4000, 6000)); assert!(!f.edits_image(), "reading the file is not editing it"); // The prologue must still be emitted, or the turn never happens. assert!(f.is_active()); // And the panel reads back neutral, because the user turned nothing. assert_eq!(f.param(ROTATION), 0.0); assert_eq!(f.param(FLIP_H), 0.0); } #[test] fn reset_returns_to_the_file_as_it_is_not_to_the_sensor_as_it_scanned() { // Reset means "undo my edits". Dropping the baseline here would lay // every portrait frame back on its side, which reads as a bug in // reset rather than as the deliberate act it would be. let mut f = Framing::new(); f.set_baseline(Orientation::from_exif(8)); f.rotate_quarters(1); f.set_param(ANGLE, -1.5); f.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); f.reset(); assert_eq!(f.baseline(), Orientation::from_exif(8)); assert_eq!(f.output_size(6000, 4000), (4000, 6000)); assert!(!f.edits_image()); assert_eq!(f.param(ROTATION), 0.0); assert_eq!(f.angle(), 0.0); assert!(f.crop().is_full()); } #[test] fn a_user_turn_lands_where_it_would_on_an_untagged_file() { // A quarter turn is a quarter turn: whatever the file's baseline, one // press of the button must move the image by 90°, and four must // return it. Otherwise the control means different things on portrait // and landscape files. for tag in 1..=8u16 { let mut f = Framing::new(); f.set_baseline(Orientation::from_exif(tag)); let upright = f.output_size(6000, 4000); f.rotate_quarters(1); let (w, h) = f.output_size(6000, 4000); assert_eq!((w, h), (upright.1, upright.0), "tag {tag}"); f.rotate_quarters(3); assert_eq!(f.output_size(6000, 4000), upright, "tag {tag}"); assert!(!f.edits_image(), "tag {tag}: four turns is back to neutral"); } } #[test] fn stored_orientation_reaches_the_shader_and_the_pipeline_cache() { // A neutral graph on a sideways file must not compile the neutral // prologue — that is the path where the tag is read, recorded, and // then silently ignored because nothing asked for a turn. let plain = Framing::new(); let mut sideways = Framing::new(); sideways.set_baseline(Orientation::from_exif(6)); assert_ne!(plain.structure_key(), sideways.structure_key()); assert!(sideways.wgsl_prologue().contains("90° clockwise")); // Still exact: an orientation is a permutation, never a resample. assert!(!sideways.needs_interpolation()); } #[test] fn a_fresh_framing_is_neutral() { // The invariant behind "opening an image shows the image". let f = Framing::new(); 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 or the crop. If it did, exporting // while zoomed would write the zoomed view. // // The structure key is deliberately not asserted here — see // `zooming_from_neutral_changes_the_structure_key` for why it must // move, and `zoom_level_does_not_change_the_structure_key` for the // part that must not. let mut f = Framing::new(); let before_size = f.output_size(6000, 4000); 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!(f.crop().is_full(), "zoom altered the crop"); } #[test] fn zooming_from_neutral_changes_the_structure_key() { // The regression this guards: a neutral framing emits a prologue that // never reads `u.crop_rect`, so if zooming leaves the key alone the // GPU reuses that pipeline and the uploaded view is ignored — zoom // silently does nothing on an otherwise-unedited image. let mut f = Framing::new(); let neutral = f.structure_key(); f.set_view(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); assert_ne!( f.structure_key(), neutral, "a zoomed framing generates different WGSL and must not share the \ neutral cache key" ); } #[test] fn zoom_level_does_not_change_the_structure_key() { // The other half of the contract: crossing from unzoomed to zoomed is // a recompile, but every notch after that is a uniform upload. If the // magnitude reached the key, every wheel step would stall on a build. let mut f = Framing::new(); f.set_view(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); let zoomed = f.structure_key(); for extent in [0.4, 0.3, 0.2, 0.1] { f.set_view(CropRect { x: 0.1, y: 0.1, width: extent, height: extent, }); assert_eq!( f.structure_key(), zoomed, "zoom level {extent} forced a recompile" ); } } #[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] fn neutral_framing_still_produces_a_position_for_the_warp_chain() { // The prologue always defines `p` and `aspect`, active or not — the // warp chain and the sampler read them either way, so a neutral // framing that skipped them would fail to compile rather than // rendering an unframed image. let src = Framing::new().wgsl_prologue(); assert!(src.contains("var p ="), "{src}"); assert!(src.contains("let aspect ="), "{src}"); // ...but none of the transform steps. assert!(!src.contains("crop_rect")); assert!(!src.contains("framing_angle")); } #[test] fn neutral_framing_emits_no_stage_marker() { // `---- ` markers count *active* stages, and a neutral graph must // generate none — the assertion behind "opening an image shows the // image" is written against that count. assert!(!Framing::new().wgsl_prologue().contains("---- ")); let mut f = Framing::new(); f.set_param(ANGLE, 2.0); assert!(f.wgsl_prologue().contains("---- framing ----")); } #[test] fn an_active_framing_reads_the_crop_rect() { let mut f = Framing::new(); f.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); assert!(f.wgsl_prologue().contains("u.crop_rect")); } #[test] fn cropping_changes_the_output_size() { let mut f = Framing::new(); f.set_crop(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); assert!(f.is_active()); assert_eq!(f.output_size(1000, 800), (500, 400)); } #[test] fn a_quarter_turn_swaps_the_output_axes() { // What makes a landscape frame come out portrait: the output is // genuinely taller than it is wide. let mut f = Framing::new(); f.rotate_quarters(1); assert_eq!(f.output_size(6000, 4000), (4000, 6000)); f.rotate_quarters(1); assert_eq!(f.output_size(6000, 4000), (6000, 4000)); } #[test] fn crop_applies_within_the_rotated_frame() { // Half of a rotated frame must be half of the *rotated* dimensions, // or a crop drawn on screen after a rotation lands somewhere else. let mut f = Framing::new(); f.rotate_quarters(1); f.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 1.0, }); assert_eq!(f.output_size(6000, 4000), (2000, 6000)); } #[test] fn quarter_turns_wrap_in_both_directions() { let mut f = Framing::new(); f.rotate_quarters(-1); assert_eq!(f.quarter_turns(), 3); f.rotate_quarters(1); assert_eq!(f.quarter_turns(), 0); f.rotate_quarters(7); assert_eq!(f.quarter_turns(), 3); } #[test] fn a_crop_cannot_be_driven_degenerate() { // A zero-extent crop produces a zero-sized texture, which is a device // error rather than a visibly silly image. let mut f = Framing::new(); f.set_crop(CropRect { x: 0.5, y: 0.5, width: 0.0, height: 0.0, }); let (w, h) = f.output_size(1000, 1000); assert!(w >= 1 && h >= 1); assert!(f.crop().width >= CropRect::MIN_EXTENT); } #[test] fn a_crop_pushed_past_the_edge_stays_inside() { let mut f = Framing::new(); f.set_crop(CropRect { x: 0.8, y: 0.9, width: 0.5, height: 0.5, }); let c = f.crop(); assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge"); assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge"); } #[test] fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() { // Worse than a wrong image: `NaN as u32` is 0, and a zero-sized // texture is a device error. let mut f = Framing::new(); f.set_param(CROP_W, f32::NAN); f.set_param(CROP_X, f32::INFINITY); let (w, h) = f.output_size(1000, 1000); assert!(w >= 1 && h >= 1); assert!(f.crop().width.is_finite() && f.crop().x.is_finite()); } #[test] fn an_origin_at_its_limit_does_not_panic() { // Found by the codegen test that drives every parameter to its // maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to // just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted // range rather than resolving it — a crash reachable by dragging a // crop handle to the edge. for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] { let c = CropRect { x: origin, y: origin, width: 1.0, height: 1.0, } .normalised(); assert!( c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT, "origin {origin} produced a degenerate rect: {c:?}" ); } } #[test] fn every_parameter_at_its_extremes_is_survivable() { // The whole descriptor driven to both ends, which is what a codegen // test does and what a corrupt sidecar can do. for p in DESCRIPTOR.params { for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] { let mut f = Framing::new(); f.set_param(p.id, p.clamp(value)); let (w, h) = f.output_size(6000, 4000); assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id); assert!(f.uniforms().iter().all(|v| v.is_finite())); } } } #[test] fn output_size_rounds_rather_than_truncating() { // Truncation biases every crop smaller; half of 101 should be 51. let mut f = Framing::new(); f.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 0.5, }); assert_eq!(f.output_size(101, 101), (51, 51)); } #[test] fn quarter_turns_and_flips_need_no_interpolation() { // Why 90° steps are handled apart from the free angle: they have an // exact answer and must not be resampled. let mut f = Framing::new(); f.rotate_quarters(1); f.set_param(FLIP_H, 1.0); assert!(f.is_active()); assert!(!f.needs_interpolation()); } #[test] fn a_free_angle_needs_interpolation() { let mut f = Framing::new(); f.set_param(ANGLE, 1.5); assert!(f.needs_interpolation()); assert!(f.wgsl_prologue().contains("u.framing_angle")); } #[test] fn a_quarter_turn_corrects_for_aspect_across_the_swap() { // `p` is scaled by the source aspect, so a permutation that exchanges // the axes has to undo and reapply it. Without that a 90° turn on a // 3:2 frame comes out stretched. let mut f = Framing::new(); f.rotate_quarters(1); assert!(f.wgsl_prologue().contains("aspect.x")); } #[test] fn the_structure_key_ignores_magnitudes() { // What the pipeline cache depends on: dragging the straighten slider // or the crop handles must not recompile. let mut a = Framing::new(); a.set_param(ANGLE, 1.0); let mut b = Framing::new(); b.set_param(ANGLE, 4.0); assert_eq!(a.structure_key(), b.structure_key()); assert_eq!(a.wgsl_prologue(), b.wgsl_prologue()); assert_ne!(a.uniforms(), b.uniforms()); let mut c = Framing::new(); c.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); let mut d = Framing::new(); d.set_crop(CropRect { x: 0.2, y: 0.2, width: 0.4, height: 0.4, }); assert_eq!(c.structure_key(), d.structure_key()); assert_eq!(c.wgsl_prologue(), d.wgsl_prologue()); } #[test] fn different_transforms_take_different_structure_keys() { // The other half of the cache contract: framing that generates // different code must not reuse another's pipeline. let mut seen = std::collections::BTreeSet::new(); seen.insert(Framing::new().structure_key()); let mut cropped = Framing::new(); cropped.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); assert!(seen.insert(cropped.structure_key())); let mut angled = Framing::new(); angled.set_param(ANGLE, 2.0); assert!(seen.insert(angled.structure_key())); let mut flipped = Framing::new(); flipped.set_param(FLIP_H, 1.0); assert!(seen.insert(flipped.structure_key())); for turns in 1..=3 { let mut f = Framing::new(); f.rotate_quarters(turns); assert!(seen.insert(f.structure_key()), "{turns} quarter turns"); } } #[test] fn parameters_round_trip() { let mut f = Framing::new(); for (id, v) in [ (ANGLE, 2.5), (ROTATION, 2.0), (FLIP_H, 1.0), (FLIP_V, 1.0), (CROP_X, 0.1), (CROP_Y, 0.2), (CROP_W, 0.5), (CROP_H, 0.4), ] { f.set_param(id, v); assert_eq!(f.param(id), v, "{id} did not round-trip"); } } #[test] fn every_default_leaves_the_stage_neutral() { // The same contract the operations honour, checked against the // descriptor rather than a literal. let mut f = Framing::new(); for p in DESCRIPTOR.params { f.set_param(p.id, p.default); } assert!(!f.is_active(), "descriptor defaults must be neutral"); } #[test] fn every_default_is_within_its_declared_range() { for p in DESCRIPTOR.params { assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id); } } #[test] fn no_parameter_is_declared_twice() { let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect(); let before = ids.len(); ids.sort_unstable(); ids.dedup(); assert_eq!(before, ids.len(), "framing has a duplicate parameter"); } #[test] fn reset_returns_to_neutral() { let mut f = Framing::new(); f.set_param(ANGLE, 3.0); f.rotate_quarters(1); f.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.3, height: 0.3, }); assert!(f.is_active()); f.reset(); assert!(!f.is_active()); assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue()); } #[test] fn uniforms_carry_the_angle_as_sin_and_cos() { // The shader never sees degrees: converting here keeps a trig call // out of every pixel. let mut f = Framing::new(); f.set_param(ANGLE, 90.0); let u = f.uniforms(); assert!( (u[4] - 1.0).abs() < 1e-6, "sin(90°) should be 1, got {}", u[4] ); assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]); } #[test] fn uniforms_are_always_finite() { // One NaN in the uniform block blanks every pixel. let mut f = Framing::new(); f.set_param(ANGLE, f32::NAN); f.set_param(CROP_W, f32::NAN); assert!( f.uniforms().iter().all(|v| v.is_finite()), "{:?}", f.uniforms() ); } #[test] fn the_uniform_block_is_vec4_aligned() { // Emitted as whole `vec4`s; a size not divisible by four would // misalign every operation uniform that follows it. assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0); assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS); } #[test] fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() { assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full()); } #[test] fn the_inscribed_crop_shrinks_as_the_angle_grows() { // Straightening further must cut in further; anything else leaves // undefined corners inside the frame. let mut small = Framing::new(); small.set_param(ANGLE, 2.0); let mut large = Framing::new(); large.set_param(ANGLE, 10.0); let a = small.max_inscribed_crop(6000, 4000); let b = large.max_inscribed_crop(6000, 4000); assert!(a.width > b.width, "{} should exceed {}", a.width, b.width); assert!(a.width < 1.0, "a rotated frame cannot keep its full width"); } #[test] fn the_inscribed_crop_is_centred_and_inside_the_frame() { for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] { let mut f = Framing::new(); f.set_param(ANGLE, angle); for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] { let c = f.max_inscribed_crop(w, h); assert!( c.width > 0.0 && c.height > 0.0, "{angle}° on {w}x{h}: {c:?} is degenerate" ); assert!( c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4, "{angle}° on {w}x{h}: {c:?} extends past the frame" ); assert!( ((c.x + c.width * 0.5) - 0.5).abs() < 1e-4, "{angle}° on {w}x{h}: {c:?} is not centred" ); } } } #[test] fn the_inscribed_crop_contains_no_undefined_area() { // The property the derivation exists for, checked directly: every // corner of the inscribed rect, mapped through the same transform the // shader applies, must land inside the source. for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] { let mut f = Framing::new(); f.set_param(ANGLE, angle); let (w, h) = (6000.0f32, 4000.0f32); let c = f.max_inscribed_crop(6000, 4000); let rad = angle * PI / 180.0; let (sn, cs) = (rad.sin(), rad.cos()); let aspect = w / h; for (fx, fy) in [ (c.x, c.y), (c.x + c.width, c.y), (c.x, c.y + c.height), (c.x + c.width, c.y + c.height), ] { let (px, py) = ((fx - 0.5) * aspect, fy - 0.5); let (rx, ry) = (px * cs - py * sn, px * sn + py * cs); let (ux, uy) = (rx / aspect + 0.5, ry + 0.5); assert!( (-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy), "{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source" ); } } } }