Nothing read EXIF orientation, so every frame from a body held sideways lay on its side — in the grid, in develop, and in the read-only preview. The tag is honoured as part of *reading the file*, at the same standing as a RAW's masked-photosite crop, never as an edit. It lives as a baseline on Framing rather than as a starting value for quarter_turns, which is what keeps four things true: a sideways file opens unmodified, reset returns it to upright rather than to the sensor's scan order, its sidecar stays empty, and the rotate button still moves the image 90° whatever the file underneath it says. Framing::effective composes the baseline with the user's own turns through the group law rather than by adding turns and OR-ing flags. The naive version gets one case wrong — an odd baseline turn plus a user mirror — and gets it wrong quietly, because the result is still a plausible orientation. The composition collapses to a single permutation, so obeying the tag costs nothing per pixel. dr_decode::orientation is a header-only IFD walk, separate from metadata() for the reason the entry points are separate at all: the grid asks once per cell and must not build a rawler decoder to get one tag. CR3 and RAF fall back to the full read, being neither TIFF nor JPEG. Written down as FR-DEV-3h. Known gap: thumbnails cached before this stay sideways. The store is keyed by file and size, and its shards sync — invalidating them would have every client re-download 25 MB a shard, which is not this commit's call to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1482 lines
54 KiB
Rust
1482 lines
54 KiB
Rust
//! 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>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||
var p = (uv - vec2<f32>(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>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||
",
|
||
);
|
||
|
||
s.push_str(
|
||
"
|
||
// Into the crop rect.
|
||
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
||
var p = (uv - vec2<f32>(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<f32>(
|
||
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<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
||
2 => " p = -p;",
|
||
_ => " p = vec2<f32>(-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"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|