Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1833 lines
69 KiB
Rust
1833 lines
69 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::{
|
||
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: OpDescriptor = OpDescriptor {
|
||
// The shape of the frame, and the only operation that changes the
|
||
// output's dimensions.
|
||
attributes: &[Attribute::Geometry],
|
||
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
|
||
}
|
||
|
||
/// 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<Presentation> {
|
||
Some(Presentation {
|
||
widgets: &[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,
|
||
})
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// 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>(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);
|
||
",
|
||
);
|
||
|
||
// 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>(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<f32>(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<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. 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<f32>(p.y * aspect.x, -p.x / frame_aspect.x);",
|
||
2 => " p = -p;",
|
||
_ => " p = vec2<f32>(-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.
|
||
#[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_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>(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");
|
||
}
|
||
}
|
||
|
||
#[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<f32>(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<f32>(-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<f32>(0.5)"),
|
||
"the sampler's return to texture coordinates moved"
|
||
);
|
||
}
|
||
}
|