//! 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 std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit, WidgetDemand, WidgetKind, }; 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; /// The parameters the framing widget owns — every one of them. /// /// In the order the widget expects: the rect first, then the angle it is /// straightened by, then the exact reorientations. static FRAMING_PARAMS: [ParamId; 8] = [ CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V, ]; static DESCRIPTOR: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { // The shape of the frame, and the only operation that changes the // output's dimensions. attributes: vec![Attribute::Geometry], id: ID, label: LocalizedKey("op.framing"), params: vec![ // 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), } } /// TRACES: FR-DEV-3 /// Reshape onto an aspect ratio, holding one point of the rect still. /// /// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which /// is what a photographer means by an aspect ratio and is emphatically not /// what this rect stores. The rect is in fractions of a frame that is /// itself not square, so the ratio it needs is `ratio * height / width` of /// that frame. Skipping the conversion yields a "1:1" crop that is square /// only on a square photograph, which is the one case nobody tests on. /// /// `anchor` is the point of the rect that stays put, in the rect's own /// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged, /// so the far corner is the one that does not move, and `(0.5, 0.5)` when /// a ratio is chosen and the composition should stay where it is. /// /// **The rect grows onto the ratio rather than shrinking onto it.** The /// axis that is short is extended; the long one is never trimmed. Fitting /// inside instead reads as a dead control — dragging a corner outward /// along one axis alone would be immediately clamped back by the other, /// and the handle would simply refuse to move. The result is then scaled /// down, both axes together, only as far as the frame's edge demands. pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self { let rect = self.normalised(); let ratio = finite(ratio, 0.0); if ratio <= 0.0 || frame_w == 0 || frame_h == 0 { return rect; } // Width over height as a fraction of *this* frame, not in pixels. let r = ratio * frame_h as f32 / frame_w as f32; let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0); let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0); // Where the fixed point sits in the frame. Everything below is built // outwards from it, which is what makes the anchor mean anything. let px = rect.x + ax * rect.width; let py = rect.y + ay * rect.height; let mut w = rect.width.max(rect.height * r); let mut h = w / r; // Scaled to fit, never clamped to fit: clamping one axis against the // frame would break the very ratio this exists to hold. let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0); if shrink.is_finite() && shrink > 0.0 { w *= shrink; h *= shrink; } Self { x: px - ax * w, y: py - ay * h, width: w, height: h, } .normalised() } /// TRACES: FR-DEV-3 /// The largest copy of this rect, at its own shape, sitting inside /// `bound`. /// /// Scaled down only as far as `bound` demands and then slid inside it, /// rather than replaced by `bound` or centred within it. Both of those /// throw away a composition: a crop placed deliberately off-centre is a /// decision, and an automatic correction that recentres it has undone the /// user's work to fix a problem they did not have. pub fn fitted_into(self, bound: Self) -> Self { let rect = self.normalised(); let bound = bound.normalised(); let shrink = (bound.width / rect.width) .min(bound.height / rect.height) .min(1.0); let shrink = if shrink.is_finite() && shrink > 0.0 { shrink } else { 1.0 }; let (w, h) = (rect.width * shrink, rect.height * shrink); // Shrunk about its own centre, so what was framed stays framed — but // only when it actually shrank. Routing the untouched case through // the same arithmetic moves the origin by a rounding error, and a // rect that is already inside its bound must come back *identical*: // this is called on every straighten, and a rect that drifts a // millionth each time is an edit recorded for no reason. let (x, y) = if shrink < 1.0 { ( rect.x + (rect.width - w) * 0.5, rect.y + (rect.height - h) * 0.5, ) } else { (rect.x, rect.y) }; Self { // `max` on the upper limit for the same reason `normalised` // orders its bounds that way: rounding can put the far edge a // hair *below* the near one, and `clamp` panics on an inverted // range rather than resolving it. x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)), y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)), width: w, height: h, } .normalised() } } /// How far a rect anchored at `p` with the anchor `a` fractions along it may /// extend before it leaves the `0..1` frame. /// /// One axis at a time, and infinite where the anchor is at the far end and the /// rect therefore only grows away from that edge. fn room(p: f32, a: f32) -> f32 { let before = if a > 0.0 { p / a } else { f32::INFINITY }; let after = if a < 1.0 { (1.0 - p) / (1.0 - a) } else { f32::INFINITY }; before.min(after) } /// 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, so it is excluded from [`Self::is_active`], from the /// structure hash, and from the sidecar. /// /// **That is not on its own enough to make an export ignore it**, and /// this doc comment used to claim it was. The exclusions keep the view out /// of the *edit* — out of what is saved, out of the output size, out of /// the crop. They cannot keep it out of a *render*, because /// [`Self::visible_rect`] deliberately folds it into the one rect the /// shader samples. Anything composing this framing and rendering it gets /// the zoom; a caller that wants the photograph rather than the canvas /// has to suspend the view first, as `DevelopSession::render_the_file` /// and `render_uncropped` both do. Exporting at 4:1 wrote the middle of /// the frame, magnified, until it did. /// /// 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) -> Arc { DESCRIPTOR.clone() } /// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7 /// How framing would like to be presented. /// /// **This is what stops a frontend having to name this stage.** Rendered /// generically these eight parameters are eight bad controls: four crop /// edges the photographer would have to type coordinates into, a "rotate" /// slider running 0..3, and two switches. Every one of them is a worse /// control than the gesture it stands for — a crop is dragged on the /// photograph and a quarter turn is a button. /// /// Before this existed, the frontend knew that by *checking the operation /// id* and skipping it, which is precisely the naming ARCH §4.3a forbids: /// a second frontend would have had to learn the same special case, and /// nothing in the capability output said why. Now the preference is /// declared, the demand says what the widget needs, and a frontend that /// cannot meet it falls back to the eight sliders — tedious, but complete, /// which is the guarantee the whole hint mechanism rests on. /// /// The widget owns **all eight** parameters rather than only the rect: a /// frontend that takes this on is taking on the whole framing control /// surface, and leaving rotation and the flips behind would scatter them /// into the generated panel underneath a crop control that already exists. pub fn presentation(&self) -> Option { Some(Presentation { widgets: vec![WidgetKind::CropOverlay], demand: WidgetDemand { // A crop rect is dragged by its corners; nothing about that // reduces to one axis at a time. two_dimensional: true, // Deliberately false. The handles are large and a crop is // forgiving — FR-UI-7 has the interaction regions grow to the // modality, so a thumb is as workable as a mouse. precise_pointing: false, }, params: FRAMING_PARAMS.to_vec(), }) } /// 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; } /// TRACES: FR-DEV-3 | FR-DEV-3h /// 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. /// /// Returned as an [`dr_types::Orientation`] because that is what it *is* /// — a quarter turn and two mirrors — and because saying so lets a /// caller outside the render reuse /// [`dr_types::Orientation::source_pixel`] rather than write the /// permutation out a second time. `dr-ui`'s segmentation is that caller: /// the detector has to read the photograph the way the photographer does, /// and the thumbnail path already turns its pixels with the same /// function. pub fn effective_orientation(&self) -> dr_types::Orientation { 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) }; dr_types::Orientation { quarter_turns: (b.quarter_turns + self.quarter_turns) % 4, flip_h: b.flip_h != ux, flip_v: b.flip_v != uy, } } fn effective(&self) -> (u8, bool, bool) { let o = self.effective_orientation(); (o.quarter_turns, o.flip_h, o.flip_v) } /// 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() } /// TRACES: FR-DEV-3 /// Where an output point comes from in the source, both in normalised /// `0..1` coordinates. /// /// **This is [`Self::wgsl_prologue`] evaluated on the CPU**, for the one /// caller that cannot run the shader: an interface hit-testing a control /// drawn *on the photograph*. A gradient's handles are stored in source /// coordinates and dragged in output ones, and the two are separated by /// the crop, the zoom, the pan, the straightening and the turns — so a /// handle that mapped through anything less would drift off the mask the /// moment the view moved, which is exactly the fault masks are rasterised /// in source space to avoid. /// /// The two must agree step for step. They are kept together in this file, /// and `the_cpu_map_matches_the_prologue_step_for_step` below pins the /// correspondence so a change to one that is not made to the other fails /// rather than showing up as a mask that is subtly wrong only when /// straightened. pub fn source_at(&self, out: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) { let (ax, fx) = self.aspects(src_w, src_h); let rect = self.visible_rect(); // Into the crop rect, then into the framed image's own centred space. let uv = (rect.x + out.0 * rect.width, rect.y + out.1 * rect.height); let mut p = ((uv.0 - 0.5) * fx, uv.1 - 0.5); if self.angle != 0.0 { let rad = self.angle * PI / 180.0; let (s, c) = (rad.sin(), rad.cos()); p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c); } let (turns, flip_h, flip_v) = self.effective(); p = match turns { 1 => (p.1 * ax, -p.0 / fx), 2 => (-p.0, -p.1), 3 => (-p.1 * ax, p.0 / fx), _ => p, }; if flip_h { p.0 = -p.0; } if flip_v { p.1 = -p.1; } (p.0 / ax + 0.5, p.1 + 0.5) } /// Where a source point lands on the output — [`Self::source_at`] run /// backwards. /// /// Outside `0..1` when the point is cropped away or panned off screen, /// which is the honest answer: the caller draws a handle there and clips /// it, rather than being handed a clamped position that claims the mask is /// somewhere it is not. pub fn output_at(&self, src: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) { let (ax, fx) = self.aspects(src_w, src_h); let mut p = ((src.0 - 0.5) * ax, src.1 - 0.5); let (turns, flip_h, flip_v) = self.effective(); if flip_v { p.1 = -p.1; } if flip_h { p.0 = -p.0; } p = match turns { 1 => (-p.1 * fx, p.0 / ax), 2 => (-p.0, -p.1), 3 => (p.1 * fx, -p.0 / ax), _ => p, }; if self.angle != 0.0 { let rad = -self.angle * PI / 180.0; let (s, c) = (rad.sin(), rad.cos()); p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c); } let uv = (p.0 / fx + 0.5, p.1 + 0.5); let rect = self.visible_rect(); ( (uv.0 - rect.x) / rect.width.max(1e-6), (uv.1 - rect.y) / rect.height.max(1e-6), ) } /// The x components of `aspect` and `frame_aspect`, whose y is always 1. /// /// The pair the prologue puts in scope, and the distinction that makes a /// quarter turn exact: `aspect` measures the source, `frame_aspect` /// measures the frame the user is looking at, and a turn is where the two /// meet. fn aspects(&self, src_w: u32, src_h: u32) -> (f32, f32) { let ax = src_w.max(1) as f32 / src_h.max(1) as f32; (ax, if self.swaps_axes() { 1.0 / ax } else { ax }) } /// 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); ", ); // The frame `p` is measured in is the one the *user* is looking at, // and a quarter turn — the file's or the user's — has already swapped // its axes. Measuring a portrait frame with the landscape aspect // stretches one axis against the other by `(w/h)²`, which the // permutation below silently undoes but the straightening above does // not: a rotation is only a rotation in a space whose axes carry the // same scale, so in the stretched one it comes out as a shear. let frame_aspect = if self.swaps_axes() { " let frame_aspect = vec2(f32(src_dims.y) / f32(src_dims.x), 1.0);\n" } else { " let frame_aspect = aspect;\n" }; let _ = write!( s, " // Into the crop rect, then into the framed image's own centred space. {frame_aspect} uv = u.crop_rect.xy + uv * u.crop_rect.zw; var p = (uv - vec2(0.5)) * frame_aspect; " ); if self.angle != 0.0 { // Done in the aspect-corrected space, which is the whole reason // `p` is scaled by `frame_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. It is also where the two aspects meet: `p` arrives // scaled by the framed image's and has to leave scaled by the // source's, so each axis is divided by the one it is read from and // multiplied by the one it is written to. let permutation = match turns { 1 => " p = vec2(p.y * aspect.x, -p.x / frame_aspect.x);", 2 => " p = -p;", _ => " p = vec2(-p.y * aspect.x, p.x / frame_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. /// TRACES: FR-DEV-3h /// The render's map and `dr_types::Orientation`'s must be the same map. /// /// There are two ways to ask "where does this output pixel come from": /// the shader prologue (via [`Framing::source_at`], its CPU twin), and /// [`Orientation::source_pixel`], which the grid's thumbnails and the /// segmentation both go through. They are written independently and they /// have to agree, or the photograph and everything drawn over it turn /// different ways. /// /// A wrong direction is what this catches, and it is worth stating what /// that looks like: a quarter turn applied backwards is 180° from right, /// which reads as a deliberate transform rather than as a mistake. Checked /// over every EXIF tag and every user rotation on top of it, because the /// composition is where the two could agree singly and disagree together. #[test] fn the_render_and_the_orientation_map_agree() { const SW: u32 = 8; const SH: u32 = 5; for tag in 1..=8u16 { for user_turns in 0..4u8 { for user_flip_h in [false, true] { let mut f = Framing::new(); f.set_baseline(Orientation::from_exif(tag)); f.rotate_quarters(i32::from(user_turns)); f.set_param(FLIP_H, f32::from(u8::from(user_flip_h))); let effective = f.effective_orientation(); let (dw, dh) = effective.oriented_size(SW, SH); assert_eq!(f.output_size(SW, SH), (dw, dh), "tag {tag}/{user_turns}"); for y in 0..dh { for x in 0..dw { let want = effective.source_pixel(x, y, dw, dh); let out = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32); let (u, v) = f.source_at(out, SW, SH); let got = ( (u * SW as f32).floor().clamp(0.0, (SW - 1) as f32) as u32, (v * SH as f32).floor().clamp(0.0, (SH - 1) as f32) as u32, ); assert_eq!( want, got, "tag {tag}, user {user_turns} turn(s), flip_h {user_flip_h}: \ output ({x},{y}) — orientation says {want:?}, the render says {got:?}" ); } } } } } } #[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))); // The public accessor, not the private tuple: this is // the permutation `dr-ui` turns the detector's input // by, so it is the one that has to be right. let combined = f.effective_orientation(); // 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_size_or_the_crop() { // The property that makes zoom a viewing tool rather than an edit: it // must not reach the output size or the crop. // // **Renamed, because the old name promised more than the body checks // and the gap was where a real bug lived.** "Does not change the // exported image" was read as a guarantee about pixels; it is a // guarantee about two numbers. Both held perfectly while // `render_for_export` was writing the zoomed view at full size, // because the view reaches the render through `visible_rect` and // never through either of these. The pixels are guarded where pixels // exist — `dr-ui`'s `export_ignores_the_viewport`. // // 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_turned_frame_is_measured_by_its_own_aspect() { // `p` is the space the straightening rotates in, so it has to be the // space the *user* sees. Once a turn has swapped the axes — the // button's or the file's — the source's aspect is the wrong ruler: // measuring a 2:3 frame with a 3:2 aspect stretches one axis against // the other, and the rotation that follows shears instead of turning. // // The pixels are asserted in `dr-gpu`'s // `straightening_a_turned_frame_keeps_a_circle_circular`; this is the // same claim at the level the code is written at. for turns in [1u8, 3] { let mut f = Framing::new(); f.rotate_quarters(i32::from(turns)); f.set_param(ANGLE, 5.0); let src = f.wgsl_prologue(); assert!( src.contains( "let frame_aspect = vec2(f32(src_dims.y) / f32(src_dims.x), 1.0);" ), "{turns} turns must measure the frame turned:\n{src}" ); assert!(src.contains("* frame_aspect;"), "{src}"); } // An even turn leaves the axes where they were, so the two rulers are // the same one and nothing has to be recomputed. for turns in [0u8, 2] { let mut f = Framing::new(); f.rotate_quarters(i32::from(turns)); f.set_param(ANGLE, 5.0); assert!( f.wgsl_prologue().contains("let frame_aspect = aspect;"), "{turns} turns should reuse the source aspect" ); } // And the baseline reaches it the same way a button press does: a // file stored sideways is a turned frame whether or not it was edited. let mut sideways = Framing::new(); sideways.set_baseline(dr_types::Orientation::from_exif(6)); sideways.set_param(ANGLE, 5.0); assert!(sideways .wgsl_prologue() .contains("f32(src_dims.y) / f32(src_dims.x)")); } #[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"); } } // --- the aspect lock and the safe-area fit --------------------------- /// A crop's shape in output pixels, which is the thing an aspect ratio is /// about — the rect's own numbers are fractions of a frame that is not /// square, so they are not comparable to `3.0 / 2.0` on their own. fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 { (c.width * w as f32) / (c.height * h as f32) } #[test] fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() { // The whole of why `with_aspect` takes the frame size. On a 3:2 // photograph a square crop is *not* a square in fractions, and a // version that skipped the conversion would pass every test written // on a square frame. let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5)); assert!( (pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4, "expected a square in pixels, got {c:?}" ); assert!( (c.width - c.height).abs() > 0.1, "a square on a 3:2 frame must not be square in fractions: {c:?}" ); } #[test] fn every_offered_ratio_comes_back_at_the_ratio_asked_for() { for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] { let want = rw as f32 / rh as f32; for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] { let c = CropRect { x: 0.2, y: 0.3, width: 0.4, height: 0.25, } .with_aspect(fw, fh, want, (0.5, 0.5)); let got = pixel_ratio(c, fw, fh); assert!( (got / want - 1.0).abs() < 1e-3, "{rw}:{rh} on {fw}x{fh} came back as {got}" ); } } } #[test] fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() { // The reason the fit is a scale rather than a clamp: clamping one // axis at the edge would hold the rect inside at the cost of the // ratio, which is the one property the caller asked for. for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] { for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] { let c = CropRect { x: 0.05, y: 0.9, width: 0.9, height: 0.09, } .with_aspect(6000, 4000, rw as f32 / rh as f32, anchor); assert!( c.x >= -1e-5 && c.y >= -1e-5 && c.x + c.width <= 1.0 + 1e-5 && c.y + c.height <= 1.0 + 1e-5, "{rw}:{rh} at {anchor:?} left the frame: {c:?}" ); let got = pixel_ratio(c, 6000, 4000); let want = rw as f32 / rh as f32; assert!( (got / want - 1.0).abs() < 1e-3, "{rw}:{rh} at {anchor:?} lost its ratio: {got}" ); } } } #[test] fn the_anchor_corner_is_the_one_that_does_not_move() { // What makes a corner drag feel like a corner drag: the opposite // corner stays nailed down while the held one shapes the rect. let start = CropRect { x: 0.2, y: 0.2, width: 0.3, height: 0.3, }; // Bottom-right held still, top-left free. let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0)); assert!( (c.x + c.width - (start.x + start.width)).abs() < 1e-5, "{c:?}" ); assert!( (c.y + c.height - (start.y + start.height)).abs() < 1e-5, "{c:?}" ); // Top-left held still, bottom-right free. let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0)); assert!((c.x - start.x).abs() < 1e-5, "{c:?}"); assert!((c.y - start.y).abs() < 1e-5, "{c:?}"); } #[test] fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() { // Shrinking to fit makes a one-axis drag do nothing at all: the other // axis clamps the first straight back, and the handle refuses to move. let start = CropRect { x: 0.1, y: 0.1, width: 0.6, height: 0.3, }; let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0)); assert!( c.width >= start.width - 1e-5, "{c:?} narrower than {start:?}" ); assert!( c.height >= start.height - 1e-5, "{c:?} shorter than {start:?}" ); } #[test] fn a_free_or_nonsense_ratio_leaves_the_rect_alone() { // Nothing here should be a failure the caller has to handle: "no // ratio" is the ordinary state of the crop tool. let start = CropRect { x: 0.1, y: 0.2, width: 0.3, height: 0.4, }; for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] { assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start); } // A frame with no extent cannot define a ratio either. assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start); } #[test] fn a_rect_already_inside_its_bound_is_left_where_it_is() { let bound = CropRect { x: 0.1, y: 0.1, width: 0.8, height: 0.8, }; let rect = CropRect { x: 0.2, y: 0.2, width: 0.3, height: 0.3, }; assert_eq!(rect.fitted_into(bound), rect); } #[test] fn fitting_into_a_bound_keeps_the_shape_and_the_side_it_was_on() { // A crop placed deliberately off-centre is a decision. Recentring it // to solve a problem the user did not have would undo their work. let bound = CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }; let rect = CropRect { x: 0.0, y: 0.0, width: 0.8, height: 0.4, }; let fitted = rect.fitted_into(bound); assert!( (fitted.width / fitted.height - rect.width / rect.height).abs() < 1e-4, "shape changed: {fitted:?}" ); assert!( fitted.x >= bound.x - 1e-5 && fitted.y >= bound.y - 1e-5 && fitted.x + fitted.width <= bound.x + bound.width + 1e-5 && fitted.y + fitted.height <= bound.y + bound.height + 1e-5, "{fitted:?} is not inside {bound:?}" ); // It came from the top-left, so it should still be against those // edges rather than centred in the bound. assert!((fitted.x - bound.x).abs() < 1e-5, "{fitted:?}"); assert!((fitted.y - bound.y).abs() < 1e-5, "{fitted:?}"); } #[test] fn a_locked_crop_still_fits_inside_the_straightened_safe_area() { // The two new pieces meet here: an angle shrinks the safe area, and a // ratio the user locked has to survive being fitted into it. let mut f = Framing::new(); f.set_param(ANGLE, 7.0); let bound = f.max_inscribed_crop(6000, 4000); let locked = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5)); let fitted = locked.fitted_into(bound); assert!( (pixel_ratio(fitted, 6000, 4000) - 1.0).abs() < 1e-3, "the lock did not survive the fit: {fitted:?}" ); assert!( fitted.x >= bound.x - 1e-5 && fitted.y >= bound.y - 1e-5 && fitted.x + fitted.width <= bound.x + bound.width + 1e-5 && fitted.y + fitted.height <= bound.y + bound.height + 1e-5, "{fitted:?} is not inside {bound:?}" ); } #[test] fn fitting_into_the_whole_frame_is_the_identity() { // What makes the straighten correction reversible: at zero degrees the // safe area is the whole frame, so recomputing the crop from the // user's own rectangle hands it straight back, bit for bit. for rect in [ CropRect::default(), CropRect { x: 0.1, y: 0.2, width: 0.3, height: 0.4, }, CropRect { x: 0.0, y: 0.0, width: 1.0, height: 0.5, }, ] { assert_eq!(rect.fitted_into(CropRect::default()), rect, "{rect:?}"); } } #[test] fn a_crop_recomputed_from_intent_grows_back_as_the_angle_falls() { // The whole reason the correction is recomputed rather than // accumulated. Taking each angle's fit from the *previous* fit // ratchets — the excursion to 20 degrees is never given back — where // taking it from the user's own rectangle every time returns it. let intended = CropRect::default(); let bound_at = |deg: f32| { let mut f = Framing::new(); f.set_param(ANGLE, deg); f.max_inscribed_crop(6000, 4000) }; let out = intended.fitted_into(bound_at(20.0)); let back = intended.fitted_into(bound_at(3.0)); assert!( back.width > out.width && back.height > out.height, "3 degrees should give more back than 20 took: {out:?} -> {back:?}" ); // And all the way home. assert_eq!(intended.fitted_into(bound_at(0.0)), intended); // The ratcheting version, for contrast: chaining the fits never // recovers, which is the behaviour this arrangement exists to avoid. let chained = out.fitted_into(bound_at(3.0)); assert!( chained.width <= out.width + 1e-6, "a chained fit must not grow — that is the bug being pinned" ); } #[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" ); } } } // ---- the CPU coordinate map (source_at / output_at) ------------------- // // These matter because the map has no other check on it. The shader's // version is verified by the picture looking right; this one is read by // hit-testing, where being wrong means a handle that grabs nothing and // nothing on screen says why. /// A 3:2 frame. Square would hide every aspect fault in here. const SRC: (u32, u32) = (600, 400); fn close(a: (f32, f32), b: (f32, f32), what: &str) { assert!( (a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4, "{what}: {a:?} != {b:?}" ); } #[test] fn an_unedited_frame_maps_an_output_point_to_itself() { // The neutral prologue is `uv_src = uv`, and a map that quietly // introduced an aspect factor here would put every mask a little off // on every unedited photograph — the case that is never looked at // twice. let f = Framing::new(); for out in [(0.0, 0.0), (0.5, 0.5), (0.25, 0.8), (1.0, 1.0)] { close(f.source_at(out, SRC.0, SRC.1), out, "neutral"); } } #[test] fn the_map_round_trips_through_every_transform_at_once() { // Handles are drawn with `output_at` and dragged with `source_at`, so // a discrepancy between them is a handle that jumps away from the // pointer on the first press. Every stage is on, because the faults // that survive are the ones only a composition exposes — an aspect // applied on one leg and not the other cancels under a bare rotation. for turns in 0..4 { let mut f = Framing::new(); f.set_crop(CropRect { x: 0.1, y: 0.2, width: 0.6, height: 0.5, }); f.set_view(CropRect { x: 0.3, y: 0.25, width: 0.4, height: 0.4, }); f.set_param(ANGLE, -7.5); f.rotate_quarters(turns); f.set_param(FLIP_H, 1.0); f.set_param(FLIP_V, 1.0); for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] { let src = f.source_at(out, SRC.0, SRC.1); close(f.output_at(src, SRC.0, SRC.1), out, "round trip"); } } } #[test] fn a_stored_orientation_is_part_of_the_map() { // The baseline reaches the prologue through `effective`, so it has to // reach this the same way. A portrait frame the camera stored sideways // is the common case, and a map that ignored the tag would place every // handle on a photograph that is not the one on screen. let mut f = Framing::new(); f.set_baseline(dr_types::Orientation { quarter_turns: 1, flip_h: false, flip_v: false, }); // The output's top-left comes from the source's bottom-left under a // clockwise quarter turn. close(f.source_at((0.0, 0.0), SRC.0, SRC.1), (0.0, 1.0), "turned"); close( f.output_at((0.0, 1.0), SRC.0, SRC.1), (0.0, 0.0), "turned back", ); } #[test] fn zooming_in_narrows_what_an_output_point_reaches() { // The property the handles depend on: the same place on screen is a // *different* source point once the view moves, so a handle drawn from // stored geometry has to be re-placed on every frame of a pan. If this // were independent of the view the handles would sit still while the // photograph slid under them. let mut f = Framing::new(); let wide = f.source_at((0.25, 0.25), SRC.0, SRC.1); f.set_view(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); let close_in = f.source_at((0.25, 0.25), SRC.0, SRC.1); assert!(close_in.0 > wide.0 && close_in.1 > wide.1, "{close_in:?}"); close(close_in, (0.375, 0.375), "zoomed"); } #[test] fn the_cpu_map_matches_the_prologue_step_for_step() { // The two are the same function written twice, and nothing but this // stops them drifting apart. It checks the *shape* — that the // permutation the prologue emits for each turn is the one implemented // above — because the alternative is running WGSL in a unit test. let mut f = Framing::new(); f.rotate_quarters(1); assert!( f.wgsl_prologue() .contains("p = vec2(p.y * aspect.x, -p.x / frame_aspect.x);"), "the one-turn permutation moved; `source_at` must move with it" ); let mut f = Framing::new(); f.rotate_quarters(3); assert!( f.wgsl_prologue() .contains("p = vec2(-p.y * aspect.x, p.x / frame_aspect.x);"), "the three-turn permutation moved; `source_at` must move with it" ); // And the sampler's last step, which lives in `operation.rs` and is // the half of the map this file does not emit. assert!( crate::operation::sample_source(false).contains("p / aspect + vec2(0.5)"), "the sampler's return to texture coordinates moved" ); } }