`lens.rs` has held a `Warp` trait, a composer and two implementations — distortion and lateral chromatic aberration — since they were written, and `compose_warps` was called by nothing outside its own tests. The corrections existed, were correct, and never touched a photograph. `EditGraph` now holds them, and `compose_full` emits them between the framing prologue and the fetch. Distortion first, then CA: each warp receives the position the previous one produced, and lateral CA is a magnification about the optical axis of the *undistorted* frame, so measured on a barrel-distorted one it would be fitted to a radius no profile describes. They reach the panel the way framing already does — through `capabilities`. That was the one open question and existing practice answered it: framing is also not an `Operation`, also has parameters a photographer sets, and also arrives through that list. Because `Preset::capture` walks the same list, the sidecar, the clipboard and the undo stack carry a warp's parameters with nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a). `state()` destructures `EditGraph` field by field precisely so that a new field cannot be forgotten, and it was not. Chromatic aberration is the only thing that samples per channel, and `splits_channels` is what keeps everything else from paying for it. Red and blue are fetched from positions green is not — green is the reference and never moves, so a wrong correction still leaves one channel sharp rather than softening all three. With no CA in the chain the single-fetch path is emitted instead. The interpolating sampler is now chosen by framing *or* an active warp. Asking framing alone would have nearest-neighboured a distortion correction on an unstraightened frame, and that aliasing reads as a bad profile rather than as a missing filter. The warps go in the geometry invalidation key rather than the colour one: they decide which source pixel a colour is read from, so a tile cached across a distortion change would keep drawing the previous correction. The pipeline cache needs nothing new — `hash_source` already covers the generated body, and uniform values never enter it, so arming a warp recompiles and dragging it does not. Both are asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2327 lines
89 KiB
Rust
2327 lines
89 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 std::sync::{Arc, LazyLock};
|
||
|
||
use crate::descriptor::{
|
||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
|
||
Unit, WidgetDemand, WidgetKind,
|
||
};
|
||
use crate::operation::Affects;
|
||
|
||
pub const ID: OpId = OpId("framing");
|
||
|
||
pub const ANGLE: ParamId = ParamId("angle");
|
||
pub const ROTATION: ParamId = ParamId("rotation");
|
||
pub const FLIP_H: ParamId = ParamId("flip_h");
|
||
pub const FLIP_V: ParamId = ParamId("flip_v");
|
||
pub const CROP_X: ParamId = ParamId("crop_x");
|
||
pub const CROP_Y: ParamId = ParamId("crop_y");
|
||
pub const CROP_W: ParamId = ParamId("crop_w");
|
||
pub const CROP_H: ParamId = ParamId("crop_h");
|
||
|
||
/// Widest straightening the control offers, in degrees either way.
|
||
///
|
||
/// Straightening a horizon is a small correction; a gross reorientation is
|
||
/// what the 90° steps are for. Bounding it keeps the slider's travel where
|
||
/// the edits actually are.
|
||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
||
|
||
/// The parameters the framing widget owns — every one of them.
|
||
///
|
||
/// In the order the widget expects: the rect first, then the angle it is
|
||
/// straightened by, then the exact reorientations.
|
||
static FRAMING_PARAMS: [ParamId; 8] = [
|
||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
||
];
|
||
|
||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||
Arc::new(OpDescriptor {
|
||
// The shape of the frame, and the only operation that changes the
|
||
// output's dimensions.
|
||
attributes: vec![Attribute::Compose],
|
||
id: ID,
|
||
label: LocalizedKey("op.framing"),
|
||
params: vec![
|
||
// Straightening. Degrees rather than a normalised amount because a
|
||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||
ParamDescriptor::scalar(
|
||
"angle",
|
||
"param.angle",
|
||
-MAX_STRAIGHTEN,
|
||
MAX_STRAIGHTEN,
|
||
0.0,
|
||
Unit::None,
|
||
Scale::Linear,
|
||
2,
|
||
),
|
||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||
// and lossless, and because reorienting a frame is a different
|
||
// gesture from nudging a horizon.
|
||
ParamDescriptor::scalar(
|
||
"rotation",
|
||
"param.rotation",
|
||
0.0,
|
||
3.0,
|
||
0.0,
|
||
Unit::None,
|
||
Scale::Linear,
|
||
0,
|
||
),
|
||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||
// The crop rect, in fractions of the source. Normalised rather than
|
||
// in pixels so a crop survives being applied to a proxy, a full
|
||
// resolution render, or an export at another size — the same reason
|
||
// the viewport renders at display resolution (FR-DSP-1).
|
||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||
],
|
||
})
|
||
});
|
||
|
||
/// A normalised crop rectangle, in fractions of the source image.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct CropRect {
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub width: f32,
|
||
pub height: f32,
|
||
}
|
||
|
||
impl Default for CropRect {
|
||
fn default() -> Self {
|
||
Self {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CropRect {
|
||
/// Smallest crop the rect may be reduced to, as a fraction of the source.
|
||
///
|
||
/// A zero-extent crop produces a zero-sized output texture, which is a
|
||
/// device error rather than a visibly silly image. Bounding it here means
|
||
/// no caller has to defend against it.
|
||
pub const MIN_EXTENT: f32 = 0.01;
|
||
|
||
/// Whether this rect selects the whole image.
|
||
pub fn is_full(&self) -> bool {
|
||
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
|
||
}
|
||
|
||
/// Clamp into the unit square, keeping the rect non-degenerate.
|
||
///
|
||
/// The origin is clamped first and the extent fitted to what remains, so
|
||
/// a rect dragged past an edge slides rather than inverting.
|
||
pub fn normalised(self) -> Self {
|
||
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||
Self {
|
||
x,
|
||
y,
|
||
// `max` before `min`, not `f32::clamp`. With the origin at its
|
||
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
|
||
// an inverted range, which `clamp` panics on rather than
|
||
// resolving. Ordering it this way lets the lower bound win, which
|
||
// is also the answer that keeps the rect non-degenerate.
|
||
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
|
||
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Reshape onto an aspect ratio, holding one point of the rect still.
|
||
///
|
||
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
|
||
/// is what a photographer means by an aspect ratio and is emphatically not
|
||
/// what this rect stores. The rect is in fractions of a frame that is
|
||
/// itself not square, so the ratio it needs is `ratio * height / width` of
|
||
/// that frame. Skipping the conversion yields a "1:1" crop that is square
|
||
/// only on a square photograph, which is the one case nobody tests on.
|
||
///
|
||
/// `anchor` is the point of the rect that stays put, in the rect's own
|
||
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
|
||
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
|
||
/// a ratio is chosen and the composition should stay where it is.
|
||
///
|
||
/// **The rect grows onto the ratio rather than shrinking onto it.** The
|
||
/// axis that is short is extended; the long one is never trimmed. Fitting
|
||
/// inside instead reads as a dead control — dragging a corner outward
|
||
/// along one axis alone would be immediately clamped back by the other,
|
||
/// and the handle would simply refuse to move. The result is then scaled
|
||
/// down, both axes together, only as far as the frame's edge demands.
|
||
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
|
||
let rect = self.normalised();
|
||
let ratio = finite(ratio, 0.0);
|
||
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
|
||
return rect;
|
||
}
|
||
|
||
// Width over height as a fraction of *this* frame, not in pixels.
|
||
let r = ratio * frame_h as f32 / frame_w as f32;
|
||
|
||
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
|
||
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
|
||
// Where the fixed point sits in the frame. Everything below is built
|
||
// outwards from it, which is what makes the anchor mean anything.
|
||
let px = rect.x + ax * rect.width;
|
||
let py = rect.y + ay * rect.height;
|
||
|
||
let mut w = rect.width.max(rect.height * r);
|
||
let mut h = w / r;
|
||
|
||
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||
// frame would break the very ratio this exists to hold.
|
||
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
|
||
if shrink.is_finite() && shrink > 0.0 {
|
||
w *= shrink;
|
||
h *= shrink;
|
||
}
|
||
|
||
Self {
|
||
x: px - ax * w,
|
||
y: py - ay * h,
|
||
width: w,
|
||
height: h,
|
||
}
|
||
.normalised()
|
||
}
|
||
/// TRACES: FR-DEV-3
|
||
/// The largest copy of this rect, at its own shape, sitting inside
|
||
/// `bound`.
|
||
///
|
||
/// Scaled down only as far as `bound` demands and then slid inside it,
|
||
/// rather than replaced by `bound` or centred within it. Both of those
|
||
/// throw away a composition: a crop placed deliberately off-centre is a
|
||
/// decision, and an automatic correction that recentres it has undone the
|
||
/// user's work to fix a problem they did not have.
|
||
pub fn fitted_into(self, bound: Self) -> Self {
|
||
let rect = self.normalised();
|
||
let bound = bound.normalised();
|
||
|
||
let shrink = (bound.width / rect.width)
|
||
.min(bound.height / rect.height)
|
||
.min(1.0);
|
||
let shrink = if shrink.is_finite() && shrink > 0.0 {
|
||
shrink
|
||
} else {
|
||
1.0
|
||
};
|
||
let (w, h) = (rect.width * shrink, rect.height * shrink);
|
||
|
||
// Shrunk about its own centre, so what was framed stays framed — but
|
||
// only when it actually shrank. Routing the untouched case through
|
||
// the same arithmetic moves the origin by a rounding error, and a
|
||
// rect that is already inside its bound must come back *identical*:
|
||
// this is called on every straighten, and a rect that drifts a
|
||
// millionth each time is an edit recorded for no reason.
|
||
let (x, y) = if shrink < 1.0 {
|
||
(
|
||
rect.x + (rect.width - w) * 0.5,
|
||
rect.y + (rect.height - h) * 0.5,
|
||
)
|
||
} else {
|
||
(rect.x, rect.y)
|
||
};
|
||
|
||
Self {
|
||
// `max` on the upper limit for the same reason `normalised`
|
||
// orders its bounds that way: rounding can put the far edge a
|
||
// hair *below* the near one, and `clamp` panics on an inverted
|
||
// range rather than resolving it.
|
||
x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)),
|
||
y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)),
|
||
width: w,
|
||
height: h,
|
||
}
|
||
.normalised()
|
||
}
|
||
}
|
||
|
||
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
|
||
/// extend before it leaves the `0..1` frame.
|
||
///
|
||
/// One axis at a time, and infinite where the anchor is at the far end and the
|
||
/// rect therefore only grows away from that edge.
|
||
fn room(p: f32, a: f32) -> f32 {
|
||
let before = if a > 0.0 { p / a } else { f32::INFINITY };
|
||
let after = if a < 1.0 {
|
||
(1.0 - p) / (1.0 - a)
|
||
} else {
|
||
f32::INFINITY
|
||
};
|
||
before.min(after)
|
||
}
|
||
|
||
/// Replace a non-finite value with a fallback.
|
||
///
|
||
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
|
||
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
|
||
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
|
||
fn finite(v: f32, fallback: f32) -> f32 {
|
||
if v.is_finite() {
|
||
v
|
||
} else {
|
||
fallback
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||
/// Crop, straighten, rotation and flips for one image.
|
||
///
|
||
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
|
||
/// device is recovered by re-composing rather than by re-deriving the edit
|
||
/// (ARCH §6.10).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Framing {
|
||
/// Straightening, in degrees. Positive rotates the image clockwise.
|
||
angle: f32,
|
||
/// Quarter turns clockwise, 0..=3.
|
||
quarter_turns: u8,
|
||
flip_h: bool,
|
||
flip_v: bool,
|
||
/// TRACES: FR-DEV-3h
|
||
/// How the file's pixels were stored, from its EXIF orientation.
|
||
///
|
||
/// **Not an edit**, and this is the whole reason it is a separate field
|
||
/// rather than a starting value for `quarter_turns`. A camera held
|
||
/// sideways stored its rows the way it always does and wrote a tag saying
|
||
/// so; obeying that tag is part of reading the file, not a decision the
|
||
/// user made. Folding it into `quarter_turns` would make every portrait
|
||
/// frame open already-modified, write a rotation into every sidecar, and —
|
||
/// worst — make "reset framing" lay the photograph on its side, since
|
||
/// reset's whole meaning is "back to the file as it is".
|
||
///
|
||
/// So it sits underneath: [`Self::param`] and the sidecar see only the
|
||
/// user's turns, while everything that renders or measures the frame sees
|
||
/// the two composed. It composes exactly, because a quarter turn and two
|
||
/// mirrors form a group of eight that is closed under composition — the
|
||
/// pair always collapses back to one turn and two flags, so the shader
|
||
/// still emits a single permutation and costs nothing for the baseline.
|
||
baseline: dr_types::Orientation,
|
||
crop: CropRect,
|
||
/// Which part of the framed image the viewport is looking at.
|
||
///
|
||
/// **Not an edit.** Zooming changes what you are inspecting, never what
|
||
/// the file becomes, so it is excluded from [`Self::is_active`], from the
|
||
/// structure hash, and from the sidecar.
|
||
///
|
||
/// **That is not on its own enough to make an export ignore it**, and
|
||
/// this doc comment used to claim it was. The exclusions keep the view out
|
||
/// of the *edit* — out of what is saved, out of the output size, out of
|
||
/// the crop. They cannot keep it out of a *render*, because
|
||
/// [`Self::visible_rect`] deliberately folds it into the one rect the
|
||
/// shader samples. Anything composing this framing and rendering it gets
|
||
/// the zoom; a caller that wants the photograph rather than the canvas
|
||
/// has to suspend the view first, as `DevelopSession::render_the_file`
|
||
/// and `render_uncropped` both do. Exporting at 4:1 wrote the middle of
|
||
/// the frame, magnified, until it did.
|
||
///
|
||
/// It lives here rather than in the UI because it composes with the crop
|
||
/// in the same normalised space — nesting one rect inside the other is a
|
||
/// multiply, and doing it here means the shader needs no second rect and
|
||
/// no extra uniform slot.
|
||
view: CropRect,
|
||
}
|
||
|
||
impl Default for Framing {
|
||
fn default() -> Self {
|
||
Self {
|
||
angle: 0.0,
|
||
quarter_turns: 0,
|
||
flip_h: false,
|
||
flip_v: false,
|
||
baseline: dr_types::Orientation::NORMAL,
|
||
crop: CropRect::default(),
|
||
view: CropRect::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Framing {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
pub fn descriptor(&self) -> Arc<OpDescriptor> {
|
||
DESCRIPTOR.clone()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
|
||
/// How framing would like to be presented.
|
||
///
|
||
/// **This is what stops a frontend having to name this stage.** Rendered
|
||
/// generically these eight parameters are eight bad controls: four crop
|
||
/// edges the photographer would have to type coordinates into, a "rotate"
|
||
/// slider running 0..3, and two switches. Every one of them is a worse
|
||
/// control than the gesture it stands for — a crop is dragged on the
|
||
/// photograph and a quarter turn is a button.
|
||
///
|
||
/// Before this existed, the frontend knew that by *checking the operation
|
||
/// id* and skipping it, which is precisely the naming ARCH §4.3a forbids:
|
||
/// a second frontend would have had to learn the same special case, and
|
||
/// nothing in the capability output said why. Now the preference is
|
||
/// declared, the demand says what the widget needs, and a frontend that
|
||
/// cannot meet it falls back to the eight sliders — tedious, but complete,
|
||
/// which is the guarantee the whole hint mechanism rests on.
|
||
///
|
||
/// The widget owns **all eight** parameters rather than only the rect: a
|
||
/// frontend that takes this on is taking on the whole framing control
|
||
/// surface, and leaving rotation and the flips behind would scatter them
|
||
/// into the generated panel underneath a crop control that already exists.
|
||
pub fn presentation(&self) -> Option<Presentation> {
|
||
Some(Presentation {
|
||
widgets: vec![WidgetKind::CropOverlay],
|
||
demand: WidgetDemand {
|
||
// A crop rect is dragged by its corners; nothing about that
|
||
// reduces to one axis at a time.
|
||
two_dimensional: true,
|
||
// Deliberately false. The handles are large and a crop is
|
||
// forgiving — FR-UI-7 has the interaction regions grow to the
|
||
// modality, so a thumb is as workable as a mouse.
|
||
precise_pointing: false,
|
||
},
|
||
params: FRAMING_PARAMS.to_vec(),
|
||
})
|
||
}
|
||
|
||
/// What this stage affects, for invalidation scoping (FR-DEV-3d).
|
||
pub fn affects(&self) -> Affects {
|
||
Affects::Geometry
|
||
}
|
||
|
||
pub fn crop(&self) -> CropRect {
|
||
self.crop
|
||
}
|
||
|
||
pub fn set_crop(&mut self, rect: CropRect) {
|
||
self.crop = rect.normalised();
|
||
}
|
||
|
||
/// The region of the framed image the viewport shows.
|
||
pub fn view(&self) -> CropRect {
|
||
self.view
|
||
}
|
||
|
||
/// Look at part of the framed image, in fractions of it.
|
||
///
|
||
/// The whole unit square is "fit to the viewport"; a smaller rect is
|
||
/// zoomed in. Because the render target keeps its size while the sampled
|
||
/// region shrinks, zooming *raises* the resolution the pipeline works at
|
||
/// rather than magnifying already-rendered pixels — which is what makes a
|
||
/// 1:1 inspection show real detail.
|
||
pub fn set_view(&mut self, rect: CropRect) {
|
||
self.view = rect.normalised();
|
||
}
|
||
|
||
/// Whether the viewport is showing anything other than the whole frame.
|
||
pub fn is_zoomed(&self) -> bool {
|
||
!self.view.is_full()
|
||
}
|
||
|
||
pub fn angle(&self) -> f32 {
|
||
self.angle
|
||
}
|
||
|
||
pub fn quarter_turns(&self) -> u8 {
|
||
self.quarter_turns
|
||
}
|
||
|
||
pub fn flips(&self) -> (bool, bool) {
|
||
(self.flip_h, self.flip_v)
|
||
}
|
||
|
||
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||
}
|
||
|
||
/// How the file stored its pixels — see the field.
|
||
pub fn baseline(&self) -> dr_types::Orientation {
|
||
self.baseline
|
||
}
|
||
|
||
/// Record the file's EXIF orientation.
|
||
///
|
||
/// Set once when the image is opened, before any edit is restored. It is
|
||
/// deliberately not a `set_param`: the descriptor lists what the user can
|
||
/// change, and this is a property of the file.
|
||
pub fn set_baseline(&mut self, orientation: dr_types::Orientation) {
|
||
self.baseline = orientation;
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-3h
|
||
/// The baseline and the user's turns and mirrors, collapsed into one.
|
||
///
|
||
/// Everything that renders or measures the frame goes through here; only
|
||
/// the panel's readout and the sidecar read the user's values raw.
|
||
///
|
||
/// The composition is the group law, not an addition. Writing a transform
|
||
/// as `mirrors ∘ turn`, applying the user's and then the baseline's gives
|
||
/// `Db · Rtb · Du · Rtu`, and conjugating `Du` past `Rtb` swaps its two
|
||
/// axes when that turn is odd — which is exactly the case that a naive
|
||
/// "add the turns, or the flags" gets wrong, and gets wrong silently,
|
||
/// since the result is still a valid-looking orientation.
|
||
///
|
||
/// Returned as an [`dr_types::Orientation`] because that is what it *is*
|
||
/// — a quarter turn and two mirrors — and because saying so lets a
|
||
/// caller outside the render reuse
|
||
/// [`dr_types::Orientation::source_pixel`] rather than write the
|
||
/// permutation out a second time. `dr-ui`'s segmentation is that caller:
|
||
/// the detector has to read the photograph the way the photographer does,
|
||
/// and the thumbnail path already turns its pixels with the same
|
||
/// function.
|
||
pub fn effective_orientation(&self) -> dr_types::Orientation {
|
||
let b = self.baseline;
|
||
// The user's mirrors, seen from the far side of the baseline's turn.
|
||
let (ux, uy) = if b.swaps_axes() {
|
||
(self.flip_v, self.flip_h)
|
||
} else {
|
||
(self.flip_h, self.flip_v)
|
||
};
|
||
dr_types::Orientation {
|
||
quarter_turns: (b.quarter_turns + self.quarter_turns) % 4,
|
||
flip_h: b.flip_h != ux,
|
||
flip_v: b.flip_v != uy,
|
||
}
|
||
}
|
||
|
||
fn effective(&self) -> (u8, bool, bool) {
|
||
let o = self.effective_orientation();
|
||
(o.quarter_turns, o.flip_h, o.flip_v)
|
||
}
|
||
|
||
/// Whether this stage currently changes the image.
|
||
///
|
||
/// The same contract the operations honour: neutral framing contributes
|
||
/// nothing to the generated shader, so an uncropped image reads its
|
||
/// pixels through the identity map exactly as it did before this existed.
|
||
pub fn is_active(&self) -> bool {
|
||
let (turns, flip_h, flip_v) = self.effective();
|
||
self.angle != 0.0
|
||
// Effective, not the user's: a file stored sideways needs the
|
||
// prologue emitted even on an untouched image, or it renders
|
||
// through the identity map and lies on its side.
|
||
|| turns != 0
|
||
|| flip_h
|
||
|| flip_v
|
||
|| !self.crop.is_full()
|
||
// Zoom is not an edit, but it *is* a coordinate map: without the
|
||
// prologue the shader samples the whole frame and the zoom does
|
||
// nothing. It is excluded from `structure_key` instead, so
|
||
// zooming re-uploads uniforms rather than recompiling.
|
||
|| self.is_zoomed()
|
||
}
|
||
|
||
/// Whether this stage changes the *image*, as opposed to merely what is
|
||
/// on screen.
|
||
///
|
||
/// [`Self::is_active`] answers "must the prologue be emitted", which zoom
|
||
/// also requires. This answers "would the exported file differ", which
|
||
/// zoom must never affect — it is what tells the interface whether there
|
||
/// are edits worth saving.
|
||
///
|
||
/// The baseline is excluded on purpose. Opening a portrait frame that the
|
||
/// camera stored sideways must not light the modified dot, enable the
|
||
/// reset, or persuade the sidecar there is something to save — nothing was
|
||
/// edited, the file was merely read correctly.
|
||
pub fn edits_image(&self) -> bool {
|
||
self.angle != 0.0
|
||
|| self.quarter_turns != 0
|
||
|| self.flip_h
|
||
|| self.flip_v
|
||
|| !self.crop.is_full()
|
||
}
|
||
|
||
/// Whether the axes are swapped — a 90° or 270° turn, baseline included.
|
||
fn swaps_axes(&self) -> bool {
|
||
self.effective().0 % 2 == 1
|
||
}
|
||
|
||
/// Whether the map puts output pixels between source pixels.
|
||
///
|
||
/// False for quarter turns and flips, which are permutations with an
|
||
/// exact answer. True once a free angle is involved. The composer reads
|
||
/// this to decide between an integer load and a filtered sample — and a
|
||
/// warp being active forces interpolation regardless, which is the
|
||
/// composer's call to make rather than this stage's.
|
||
pub fn needs_interpolation(&self) -> bool {
|
||
self.angle != 0.0
|
||
}
|
||
|
||
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||
match id {
|
||
ANGLE => self.angle = finite(value, 0.0),
|
||
// Descriptor-clamped to 0..3, so the cast cannot wrap.
|
||
ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8,
|
||
FLIP_H => self.flip_h = value != 0.0,
|
||
FLIP_V => self.flip_v = value != 0.0,
|
||
CROP_X => {
|
||
self.crop = CropRect {
|
||
x: value,
|
||
..self.crop
|
||
}
|
||
.normalised()
|
||
}
|
||
CROP_Y => {
|
||
self.crop = CropRect {
|
||
y: value,
|
||
..self.crop
|
||
}
|
||
.normalised()
|
||
}
|
||
CROP_W => {
|
||
self.crop = CropRect {
|
||
width: value,
|
||
..self.crop
|
||
}
|
||
.normalised()
|
||
}
|
||
CROP_H => {
|
||
self.crop = CropRect {
|
||
height: value,
|
||
..self.crop
|
||
}
|
||
.normalised()
|
||
}
|
||
_ => log::warn!("framing: unknown parameter {id}"),
|
||
}
|
||
}
|
||
|
||
pub fn param(&self, id: ParamId) -> f32 {
|
||
match id {
|
||
ANGLE => self.angle,
|
||
ROTATION => f32::from(self.quarter_turns),
|
||
FLIP_H => f32::from(u8::from(self.flip_h)),
|
||
FLIP_V => f32::from(u8::from(self.flip_v)),
|
||
CROP_X => self.crop.x,
|
||
CROP_Y => self.crop.y,
|
||
CROP_W => self.crop.width,
|
||
CROP_H => self.crop.height,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Clear every framing edit.
|
||
///
|
||
/// The baseline survives, because it was never an edit. "Reset" means
|
||
/// *the file as it is*, and the file is upright — so this returns the
|
||
/// photograph to how the camera meant it to be seen rather than to how
|
||
/// the sensor happened to be scanned.
|
||
pub fn reset(&mut self) {
|
||
*self = Self {
|
||
baseline: self.baseline,
|
||
..Self::default()
|
||
};
|
||
}
|
||
|
||
/// The output size this framing produces from a source of `(w, h)`.
|
||
///
|
||
/// The rendered aspect ratio follows from here, which is why this is the
|
||
/// one piece of framing both the UI and the GPU pass need before any
|
||
/// pixel is shaded: the output texture is allocated from it.
|
||
///
|
||
/// A free angle does **not** change the output size. The rotated image is
|
||
/// sampled into the crop rect as it stands, so straightening a horizon
|
||
/// leaves the frame where the user put it and may pull in undefined area
|
||
/// at the corners — see [`Self::max_inscribed_crop`] for the rect that
|
||
/// avoids that.
|
||
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
||
let (w, h) = if self.swaps_axes() {
|
||
(height, width)
|
||
} else {
|
||
(width, height)
|
||
};
|
||
// Round rather than truncate: half of a 101px axis should be 51, and
|
||
// truncation biases every crop smaller.
|
||
let cw = ((w as f32 * self.crop.width).round() as u32).max(1);
|
||
let ch = ((h as f32 * self.crop.height).round() as u32).max(1);
|
||
(cw, ch)
|
||
}
|
||
|
||
/// The output size ignoring the crop — the whole frame, turned.
|
||
///
|
||
/// What the crop overlay measures against: it draws the rect the user is
|
||
/// selecting, so it needs the shape being selected *from*, not the shape
|
||
/// the crop currently produces.
|
||
pub fn output_size_uncropped(&self, width: u32, height: u32) -> (u32, u32) {
|
||
if self.swaps_axes() {
|
||
(height.max(1), width.max(1))
|
||
} else {
|
||
(width.max(1), height.max(1))
|
||
}
|
||
}
|
||
|
||
/// The largest centred crop, at the current angle, containing no
|
||
/// undefined area.
|
||
///
|
||
/// Rotating a rectangle inside its own bounds exposes the corners: there
|
||
/// is no source pixel there, and the shader renders it black. This is the
|
||
/// rect that avoids it — what a "straighten and auto-crop" gesture would
|
||
/// apply, and what the crop overlay should offer as its bound.
|
||
///
|
||
/// The standard largest-inscribed-rectangle result for a rotated
|
||
/// rectangle of the same aspect ratio.
|
||
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
||
if self.angle == 0.0 || width == 0 || height == 0 {
|
||
return CropRect::default();
|
||
}
|
||
|
||
let (w, h) = if self.swaps_axes() {
|
||
(height as f32, width as f32)
|
||
} else {
|
||
(width as f32, height as f32)
|
||
};
|
||
|
||
let a = (self.angle * PI / 180.0).abs();
|
||
let (sin, cos) = (a.sin(), a.cos());
|
||
|
||
// Longer and shorter side, so the two cases below stay symmetric.
|
||
let (long, short) = if w >= h { (w, h) } else { (h, w) };
|
||
|
||
let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 {
|
||
// Half-constrained: the shorter side alone limits the rectangle.
|
||
let half = 0.5 * short;
|
||
if w >= h {
|
||
(half / sin, half / cos)
|
||
} else {
|
||
(half / cos, half / sin)
|
||
}
|
||
} else {
|
||
// Fully constrained by both sides.
|
||
let cos2 = cos * cos - sin * sin;
|
||
((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2)
|
||
};
|
||
|
||
// Back to fractions of the (possibly axis-swapped) frame, centred.
|
||
let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
CropRect {
|
||
x: (1.0 - fw) * 0.5,
|
||
y: (1.0 - fh) * 0.5,
|
||
width: fw,
|
||
height: fh,
|
||
}
|
||
.normalised()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Where an output point comes from in the source, both in normalised
|
||
/// `0..1` coordinates.
|
||
///
|
||
/// **This is [`Self::wgsl_prologue`] evaluated on the CPU**, for the one
|
||
/// caller that cannot run the shader: an interface hit-testing a control
|
||
/// drawn *on the photograph*. A gradient's handles are stored in source
|
||
/// coordinates and dragged in output ones, and the two are separated by
|
||
/// the crop, the zoom, the pan, the straightening and the turns — so a
|
||
/// handle that mapped through anything less would drift off the mask the
|
||
/// moment the view moved, which is exactly the fault masks are rasterised
|
||
/// in source space to avoid.
|
||
///
|
||
/// The two must agree step for step. They are kept together in this file,
|
||
/// and `the_cpu_map_matches_the_prologue_step_for_step` below pins the
|
||
/// correspondence so a change to one that is not made to the other fails
|
||
/// rather than showing up as a mask that is subtly wrong only when
|
||
/// straightened.
|
||
pub fn source_at(&self, out: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
|
||
let (ax, fx) = self.aspects(src_w, src_h);
|
||
let rect = self.visible_rect();
|
||
|
||
// Into the crop rect, then into the framed image's own centred space.
|
||
let uv = (rect.x + out.0 * rect.width, rect.y + out.1 * rect.height);
|
||
let mut p = ((uv.0 - 0.5) * fx, uv.1 - 0.5);
|
||
|
||
if self.angle != 0.0 {
|
||
let rad = self.angle * PI / 180.0;
|
||
let (s, c) = (rad.sin(), rad.cos());
|
||
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
||
}
|
||
|
||
let (turns, flip_h, flip_v) = self.effective();
|
||
p = match turns {
|
||
1 => (p.1 * ax, -p.0 / fx),
|
||
2 => (-p.0, -p.1),
|
||
3 => (-p.1 * ax, p.0 / fx),
|
||
_ => p,
|
||
};
|
||
if flip_h {
|
||
p.0 = -p.0;
|
||
}
|
||
if flip_v {
|
||
p.1 = -p.1;
|
||
}
|
||
|
||
(p.0 / ax + 0.5, p.1 + 0.5)
|
||
}
|
||
|
||
/// Where a source point lands on the output — [`Self::source_at`] run
|
||
/// backwards.
|
||
///
|
||
/// Outside `0..1` when the point is cropped away or panned off screen,
|
||
/// which is the honest answer: the caller draws a handle there and clips
|
||
/// it, rather than being handed a clamped position that claims the mask is
|
||
/// somewhere it is not.
|
||
pub fn output_at(&self, src: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
|
||
let (ax, fx) = self.aspects(src_w, src_h);
|
||
let mut p = ((src.0 - 0.5) * ax, src.1 - 0.5);
|
||
|
||
let (turns, flip_h, flip_v) = self.effective();
|
||
if flip_v {
|
||
p.1 = -p.1;
|
||
}
|
||
if flip_h {
|
||
p.0 = -p.0;
|
||
}
|
||
p = match turns {
|
||
1 => (-p.1 * fx, p.0 / ax),
|
||
2 => (-p.0, -p.1),
|
||
3 => (p.1 * fx, -p.0 / ax),
|
||
_ => p,
|
||
};
|
||
|
||
if self.angle != 0.0 {
|
||
let rad = -self.angle * PI / 180.0;
|
||
let (s, c) = (rad.sin(), rad.cos());
|
||
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
||
}
|
||
|
||
let uv = (p.0 / fx + 0.5, p.1 + 0.5);
|
||
let rect = self.visible_rect();
|
||
(
|
||
(uv.0 - rect.x) / rect.width.max(1e-6),
|
||
(uv.1 - rect.y) / rect.height.max(1e-6),
|
||
)
|
||
}
|
||
|
||
/// The x components of `aspect` and `frame_aspect`, whose y is always 1.
|
||
///
|
||
/// The pair the prologue puts in scope, and the distinction that makes a
|
||
/// quarter turn exact: `aspect` measures the source, `frame_aspect`
|
||
/// measures the frame the user is looking at, and a turn is where the two
|
||
/// meet.
|
||
fn aspects(&self, src_w: u32, src_h: u32) -> (f32, f32) {
|
||
let ax = src_w.max(1) as f32 / src_h.max(1) as f32;
|
||
(ax, if self.swaps_axes() { 1.0 / ax } else { ax })
|
||
}
|
||
|
||
/// Uniform values the generated prologue reads.
|
||
///
|
||
/// A fixed-size block in a fixed slot, like the camera matrix: the
|
||
/// prologue is emitted whether or not any operation is active, so its
|
||
/// uniforms cannot be positioned by the op loop.
|
||
///
|
||
/// The angle reaches the shader as sin/cos rather than degrees — a trig
|
||
/// call per pixel would recover a value constant across the dispatch.
|
||
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
|
||
let rad = self.angle * PI / 180.0;
|
||
// The view nests *inside* the crop: the prologue applies one rect,
|
||
// and two nested rects in the same normalised space compose into one.
|
||
// Doing it here rather than in the shader keeps the per-pixel work
|
||
// identical whether or not the user is zoomed in, and costs no extra
|
||
// uniform slot.
|
||
let rect = self.visible_rect();
|
||
[
|
||
rect.x,
|
||
rect.y,
|
||
rect.width,
|
||
rect.height,
|
||
rad.sin(),
|
||
rad.cos(),
|
||
0.0,
|
||
0.0,
|
||
]
|
||
}
|
||
|
||
/// The crop and the view composed into the single rect the shader samples.
|
||
///
|
||
/// Separate from [`Self::uniforms`] so the composition can be tested as
|
||
/// the piece of geometry it is, rather than through a uniform array.
|
||
pub fn visible_rect(&self) -> CropRect {
|
||
CropRect {
|
||
x: self.crop.x + self.view.x * self.crop.width,
|
||
y: self.crop.y + self.view.y * self.crop.height,
|
||
width: self.crop.width * self.view.width,
|
||
height: self.crop.height * self.view.height,
|
||
}
|
||
}
|
||
|
||
/// The WGSL mapping an output pixel to a **normalised centred** source
|
||
/// position, ready for the warp chain.
|
||
///
|
||
/// Leaves the result in `p`: centre `(0, 0)`, spanning `±0.5 * aspect` —
|
||
/// the space [`crate::lens`] documents, so lens correction composes on top
|
||
/// of this without either stage naming the other.
|
||
///
|
||
/// **`p` is centred but not corner-normalised**, and this used to claim it
|
||
/// was. Its length at the corner is `0.5 * length(aspect)`, not 1. The
|
||
/// corner-normalised radius the radial corrections need is published
|
||
/// separately as `radius` by `operation::sample_source`, which divides by
|
||
/// exactly that; anything reading `length(p)` as a lens radius is off by
|
||
/// an aspect-dependent factor.
|
||
///
|
||
/// `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.
|
||
/// TRACES: FR-DEV-3h
|
||
/// The render's map and `dr_types::Orientation`'s must be the same map.
|
||
///
|
||
/// There are two ways to ask "where does this output pixel come from":
|
||
/// the shader prologue (via [`Framing::source_at`], its CPU twin), and
|
||
/// [`Orientation::source_pixel`], which the grid's thumbnails and the
|
||
/// segmentation both go through. They are written independently and they
|
||
/// have to agree, or the photograph and everything drawn over it turn
|
||
/// different ways.
|
||
///
|
||
/// A wrong direction is what this catches, and it is worth stating what
|
||
/// that looks like: a quarter turn applied backwards is 180° from right,
|
||
/// which reads as a deliberate transform rather than as a mistake. Checked
|
||
/// over every EXIF tag and every user rotation on top of it, because the
|
||
/// composition is where the two could agree singly and disagree together.
|
||
#[test]
|
||
fn the_render_and_the_orientation_map_agree() {
|
||
const SW: u32 = 8;
|
||
const SH: u32 = 5;
|
||
|
||
for tag in 1..=8u16 {
|
||
for user_turns in 0..4u8 {
|
||
for user_flip_h in [false, true] {
|
||
let mut f = Framing::new();
|
||
f.set_baseline(Orientation::from_exif(tag));
|
||
f.rotate_quarters(i32::from(user_turns));
|
||
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
|
||
|
||
let effective = f.effective_orientation();
|
||
let (dw, dh) = effective.oriented_size(SW, SH);
|
||
assert_eq!(f.output_size(SW, SH), (dw, dh), "tag {tag}/{user_turns}");
|
||
|
||
for y in 0..dh {
|
||
for x in 0..dw {
|
||
let want = effective.source_pixel(x, y, dw, dh);
|
||
|
||
let out = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32);
|
||
let (u, v) = f.source_at(out, SW, SH);
|
||
let got = (
|
||
(u * SW as f32).floor().clamp(0.0, (SW - 1) as f32) as u32,
|
||
(v * SH as f32).floor().clamp(0.0, (SH - 1) as f32) as u32,
|
||
);
|
||
|
||
assert_eq!(
|
||
want, got,
|
||
"tag {tag}, user {user_turns} turn(s), flip_h {user_flip_h}: \
|
||
output ({x},{y}) — orientation says {want:?}, the render says {got:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
|
||
// Non-square and coprime, so no accidental symmetry hides an error.
|
||
const SW: u32 = 5;
|
||
const SH: u32 = 3;
|
||
|
||
for tag in 1..=8u16 {
|
||
let baseline = Orientation::from_exif(tag);
|
||
let (ow, oh) = baseline.oriented_size(SW, SH);
|
||
|
||
for user_turns in 0..4u8 {
|
||
for user_flip_h in [false, true] {
|
||
for user_flip_v in [false, true] {
|
||
let user = Orientation {
|
||
quarter_turns: user_turns,
|
||
flip_h: user_flip_h,
|
||
flip_v: user_flip_v,
|
||
};
|
||
|
||
let mut f = Framing::new();
|
||
f.set_baseline(baseline);
|
||
f.rotate_quarters(i32::from(user_turns));
|
||
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
|
||
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
|
||
|
||
// The public accessor, not the private tuple: this is
|
||
// the permutation `dr-ui` turns the detector's input
|
||
// by, so it is the one that has to be right.
|
||
let combined = f.effective_orientation();
|
||
|
||
// The output size the composed transform produces must
|
||
// be the one the two stages produce in sequence.
|
||
let (dw, dh) = user.oriented_size(ow, oh);
|
||
assert_eq!(
|
||
f.output_size(SW, SH),
|
||
(dw, dh),
|
||
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v}"
|
||
);
|
||
|
||
for y in 0..dh {
|
||
for x in 0..dw {
|
||
// Display -> oriented -> stored, the long way.
|
||
let (ox, oy) = user.source_pixel(x, y, dw, dh);
|
||
let stepwise = baseline.source_pixel(ox, oy, ow, oh);
|
||
// Display -> stored, in one permutation.
|
||
let fused = combined.source_pixel(x, y, dw, dh);
|
||
assert_eq!(
|
||
stepwise, fused,
|
||
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v} \
|
||
at ({x},{y})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_sideways_file_opens_upright_without_counting_as_an_edit() {
|
||
// The whole point of the baseline. A phone portrait is 4000x6000 on
|
||
// screen and 6000x4000 on disk, and none of that is the user's doing:
|
||
// no modified dot, nothing for the sidecar to save.
|
||
let mut f = Framing::new();
|
||
f.set_baseline(Orientation::from_exif(6));
|
||
|
||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||
assert_eq!(f.output_size_uncropped(6000, 4000), (4000, 6000));
|
||
assert!(!f.edits_image(), "reading the file is not editing it");
|
||
// The prologue must still be emitted, or the turn never happens.
|
||
assert!(f.is_active());
|
||
// And the panel reads back neutral, because the user turned nothing.
|
||
assert_eq!(f.param(ROTATION), 0.0);
|
||
assert_eq!(f.param(FLIP_H), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn reset_returns_to_the_file_as_it_is_not_to_the_sensor_as_it_scanned() {
|
||
// Reset means "undo my edits". Dropping the baseline here would lay
|
||
// every portrait frame back on its side, which reads as a bug in
|
||
// reset rather than as the deliberate act it would be.
|
||
let mut f = Framing::new();
|
||
f.set_baseline(Orientation::from_exif(8));
|
||
f.rotate_quarters(1);
|
||
f.set_param(ANGLE, -1.5);
|
||
f.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
|
||
f.reset();
|
||
|
||
assert_eq!(f.baseline(), Orientation::from_exif(8));
|
||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||
assert!(!f.edits_image());
|
||
assert_eq!(f.param(ROTATION), 0.0);
|
||
assert_eq!(f.angle(), 0.0);
|
||
assert!(f.crop().is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn a_user_turn_lands_where_it_would_on_an_untagged_file() {
|
||
// A quarter turn is a quarter turn: whatever the file's baseline, one
|
||
// press of the button must move the image by 90°, and four must
|
||
// return it. Otherwise the control means different things on portrait
|
||
// and landscape files.
|
||
for tag in 1..=8u16 {
|
||
let mut f = Framing::new();
|
||
f.set_baseline(Orientation::from_exif(tag));
|
||
let upright = f.output_size(6000, 4000);
|
||
|
||
f.rotate_quarters(1);
|
||
let (w, h) = f.output_size(6000, 4000);
|
||
assert_eq!((w, h), (upright.1, upright.0), "tag {tag}");
|
||
|
||
f.rotate_quarters(3);
|
||
assert_eq!(f.output_size(6000, 4000), upright, "tag {tag}");
|
||
assert!(!f.edits_image(), "tag {tag}: four turns is back to neutral");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stored_orientation_reaches_the_shader_and_the_pipeline_cache() {
|
||
// A neutral graph on a sideways file must not compile the neutral
|
||
// prologue — that is the path where the tag is read, recorded, and
|
||
// then silently ignored because nothing asked for a turn.
|
||
let plain = Framing::new();
|
||
let mut sideways = Framing::new();
|
||
sideways.set_baseline(Orientation::from_exif(6));
|
||
|
||
assert_ne!(plain.structure_key(), sideways.structure_key());
|
||
assert!(sideways.wgsl_prologue().contains("90° clockwise"));
|
||
// Still exact: an orientation is a permutation, never a resample.
|
||
assert!(!sideways.needs_interpolation());
|
||
}
|
||
|
||
#[test]
|
||
fn a_fresh_framing_is_neutral() {
|
||
// The invariant behind "opening an image shows the image".
|
||
let f = Framing::new();
|
||
assert!(!f.is_active());
|
||
assert!(!f.needs_interpolation());
|
||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||
assert!(!f.is_zoomed());
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_does_not_change_the_size_or_the_crop() {
|
||
// The property that makes zoom a viewing tool rather than an edit: it
|
||
// must not reach the output size or the crop.
|
||
//
|
||
// **Renamed, because the old name promised more than the body checks
|
||
// and the gap was where a real bug lived.** "Does not change the
|
||
// exported image" was read as a guarantee about pixels; it is a
|
||
// guarantee about two numbers. Both held perfectly while
|
||
// `render_for_export` was writing the zoomed view at full size,
|
||
// because the view reaches the render through `visible_rect` and
|
||
// never through either of these. The pixels are guarded where pixels
|
||
// exist — `dr-ui`'s `export_ignores_the_viewport`.
|
||
//
|
||
// The structure key is deliberately not asserted here — see
|
||
// `zooming_from_neutral_changes_the_structure_key` for why it must
|
||
// move, and `zoom_level_does_not_change_the_structure_key` for the
|
||
// part that must not.
|
||
let mut f = Framing::new();
|
||
let before_size = f.output_size(6000, 4000);
|
||
|
||
f.set_view(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
|
||
assert_eq!(
|
||
f.output_size(6000, 4000),
|
||
before_size,
|
||
"zoom resized output"
|
||
);
|
||
assert!(f.crop().is_full(), "zoom altered the crop");
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_from_neutral_changes_the_structure_key() {
|
||
// The regression this guards: a neutral framing emits a prologue that
|
||
// never reads `u.crop_rect`, so if zooming leaves the key alone the
|
||
// GPU reuses that pipeline and the uploaded view is ignored — zoom
|
||
// silently does nothing on an otherwise-unedited image.
|
||
let mut f = Framing::new();
|
||
let neutral = f.structure_key();
|
||
|
||
f.set_view(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
|
||
assert_ne!(
|
||
f.structure_key(),
|
||
neutral,
|
||
"a zoomed framing generates different WGSL and must not share the \
|
||
neutral cache key"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_level_does_not_change_the_structure_key() {
|
||
// The other half of the contract: crossing from unzoomed to zoomed is
|
||
// a recompile, but every notch after that is a uniform upload. If the
|
||
// magnitude reached the key, every wheel step would stall on a build.
|
||
let mut f = Framing::new();
|
||
f.set_view(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let zoomed = f.structure_key();
|
||
|
||
for extent in [0.4, 0.3, 0.2, 0.1] {
|
||
f.set_view(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: extent,
|
||
height: extent,
|
||
});
|
||
assert_eq!(
|
||
f.structure_key(),
|
||
zoomed,
|
||
"zoom level {extent} forced a recompile"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_view_nests_inside_the_crop() {
|
||
// Both rects live in the same normalised space and the shader applies
|
||
// only one, so they must compose. Getting this wrong would make
|
||
// zooming inside a crop jump to a different part of the photograph.
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.5,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
// The centre quarter *of the crop*.
|
||
f.set_view(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
|
||
let r = f.visible_rect();
|
||
// Origin: a quarter into a crop that starts at 0.5 and spans 0.5.
|
||
assert!((r.x - 0.625).abs() < 1e-6, "x was {}", r.x);
|
||
assert!((r.y - 0.125).abs() < 1e-6, "y was {}", r.y);
|
||
// Extent: half of half.
|
||
assert!((r.width - 0.25).abs() < 1e-6, "width was {}", r.width);
|
||
assert!((r.height - 0.25).abs() < 1e-6, "height was {}", r.height);
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_view_leaves_the_crop_exactly_as_it_was() {
|
||
// Composition must be an identity when unzoomed, or merely opening an
|
||
// image would shift the crop by a rounding error.
|
||
let mut f = Framing::new();
|
||
let crop = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
f.set_crop(crop);
|
||
let r = f.visible_rect();
|
||
assert!((r.x - crop.x).abs() < 1e-6);
|
||
assert!((r.y - crop.y).abs() < 1e-6);
|
||
assert!((r.width - crop.width).abs() < 1e-6);
|
||
assert!((r.height - crop.height).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn a_zoomed_framing_emits_the_coordinate_map() {
|
||
// Zoom is excluded from the structure hash but must still make the
|
||
// prologue active — otherwise the shader samples the whole frame and
|
||
// the zoom silently does nothing.
|
||
let mut f = Framing::new();
|
||
f.set_view(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
assert!(f.is_active(), "a zoomed view must emit the crop mapping");
|
||
}
|
||
|
||
#[test]
|
||
fn neutral_framing_still_produces_a_position_for_the_warp_chain() {
|
||
// The prologue always defines `p` and `aspect`, active or not — the
|
||
// warp chain and the sampler read them either way, so a neutral
|
||
// framing that skipped them would fail to compile rather than
|
||
// rendering an unframed image.
|
||
let src = Framing::new().wgsl_prologue();
|
||
assert!(src.contains("var p ="), "{src}");
|
||
assert!(src.contains("let aspect ="), "{src}");
|
||
// ...but none of the transform steps.
|
||
assert!(!src.contains("crop_rect"));
|
||
assert!(!src.contains("framing_angle"));
|
||
}
|
||
|
||
#[test]
|
||
fn neutral_framing_emits_no_stage_marker() {
|
||
// `---- ` markers count *active* stages, and a neutral graph must
|
||
// generate none — the assertion behind "opening an image shows the
|
||
// image" is written against that count.
|
||
assert!(!Framing::new().wgsl_prologue().contains("---- "));
|
||
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, 2.0);
|
||
assert!(f.wgsl_prologue().contains("---- framing ----"));
|
||
}
|
||
|
||
#[test]
|
||
fn an_active_framing_reads_the_crop_rect() {
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
assert!(f.wgsl_prologue().contains("u.crop_rect"));
|
||
}
|
||
|
||
#[test]
|
||
fn cropping_changes_the_output_size() {
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
assert!(f.is_active());
|
||
assert_eq!(f.output_size(1000, 800), (500, 400));
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_swaps_the_output_axes() {
|
||
// What makes a landscape frame come out portrait: the output is
|
||
// genuinely taller than it is wide.
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(1);
|
||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||
|
||
f.rotate_quarters(1);
|
||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||
}
|
||
|
||
#[test]
|
||
fn crop_applies_within_the_rotated_frame() {
|
||
// Half of a rotated frame must be half of the *rotated* dimensions,
|
||
// or a crop drawn on screen after a rotation lands somewhere else.
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(1);
|
||
f.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
assert_eq!(f.output_size(6000, 4000), (2000, 6000));
|
||
}
|
||
|
||
#[test]
|
||
fn quarter_turns_wrap_in_both_directions() {
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(-1);
|
||
assert_eq!(f.quarter_turns(), 3);
|
||
f.rotate_quarters(1);
|
||
assert_eq!(f.quarter_turns(), 0);
|
||
f.rotate_quarters(7);
|
||
assert_eq!(f.quarter_turns(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn a_crop_cannot_be_driven_degenerate() {
|
||
// A zero-extent crop produces a zero-sized texture, which is a device
|
||
// error rather than a visibly silly image.
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.5,
|
||
y: 0.5,
|
||
width: 0.0,
|
||
height: 0.0,
|
||
});
|
||
let (w, h) = f.output_size(1000, 1000);
|
||
assert!(w >= 1 && h >= 1);
|
||
assert!(f.crop().width >= CropRect::MIN_EXTENT);
|
||
}
|
||
|
||
#[test]
|
||
fn a_crop_pushed_past_the_edge_stays_inside() {
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.8,
|
||
y: 0.9,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let c = f.crop();
|
||
assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||
assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||
}
|
||
|
||
#[test]
|
||
fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() {
|
||
// Worse than a wrong image: `NaN as u32` is 0, and a zero-sized
|
||
// texture is a device error.
|
||
let mut f = Framing::new();
|
||
f.set_param(CROP_W, f32::NAN);
|
||
f.set_param(CROP_X, f32::INFINITY);
|
||
let (w, h) = f.output_size(1000, 1000);
|
||
assert!(w >= 1 && h >= 1);
|
||
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn an_origin_at_its_limit_does_not_panic() {
|
||
// Found by the codegen test that drives every parameter to its
|
||
// maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to
|
||
// just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted
|
||
// range rather than resolving it — a crash reachable by dragging a
|
||
// crop handle to the edge.
|
||
for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] {
|
||
let c = CropRect {
|
||
x: origin,
|
||
y: origin,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
}
|
||
.normalised();
|
||
assert!(
|
||
c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT,
|
||
"origin {origin} produced a degenerate rect: {c:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_parameter_at_its_extremes_is_survivable() {
|
||
// The whole descriptor driven to both ends, which is what a codegen
|
||
// test does and what a corrupt sidecar can do.
|
||
for p in &DESCRIPTOR.params {
|
||
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
|
||
let mut f = Framing::new();
|
||
f.set_param(p.id, p.clamp(value));
|
||
let (w, h) = f.output_size(6000, 4000);
|
||
assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id);
|
||
assert!(f.uniforms().iter().all(|v| v.is_finite()));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn output_size_rounds_rather_than_truncating() {
|
||
// Truncation biases every crop smaller; half of 101 should be 51.
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
assert_eq!(f.output_size(101, 101), (51, 51));
|
||
}
|
||
|
||
#[test]
|
||
fn quarter_turns_and_flips_need_no_interpolation() {
|
||
// Why 90° steps are handled apart from the free angle: they have an
|
||
// exact answer and must not be resampled.
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(1);
|
||
f.set_param(FLIP_H, 1.0);
|
||
assert!(f.is_active());
|
||
assert!(!f.needs_interpolation());
|
||
}
|
||
|
||
#[test]
|
||
fn a_free_angle_needs_interpolation() {
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, 1.5);
|
||
assert!(f.needs_interpolation());
|
||
assert!(f.wgsl_prologue().contains("u.framing_angle"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_turned_frame_is_measured_by_its_own_aspect() {
|
||
// `p` is the space the straightening rotates in, so it has to be the
|
||
// space the *user* sees. Once a turn has swapped the axes — the
|
||
// button's or the file's — the source's aspect is the wrong ruler:
|
||
// measuring a 2:3 frame with a 3:2 aspect stretches one axis against
|
||
// the other, and the rotation that follows shears instead of turning.
|
||
//
|
||
// The pixels are asserted in `dr-gpu`'s
|
||
// `straightening_a_turned_frame_keeps_a_circle_circular`; this is the
|
||
// same claim at the level the code is written at.
|
||
for turns in [1u8, 3] {
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(i32::from(turns));
|
||
f.set_param(ANGLE, 5.0);
|
||
let src = f.wgsl_prologue();
|
||
assert!(
|
||
src.contains(
|
||
"let frame_aspect = vec2<f32>(f32(src_dims.y) / f32(src_dims.x), 1.0);"
|
||
),
|
||
"{turns} turns must measure the frame turned:\n{src}"
|
||
);
|
||
assert!(src.contains("* frame_aspect;"), "{src}");
|
||
}
|
||
|
||
// An even turn leaves the axes where they were, so the two rulers are
|
||
// the same one and nothing has to be recomputed.
|
||
for turns in [0u8, 2] {
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(i32::from(turns));
|
||
f.set_param(ANGLE, 5.0);
|
||
assert!(
|
||
f.wgsl_prologue().contains("let frame_aspect = aspect;"),
|
||
"{turns} turns should reuse the source aspect"
|
||
);
|
||
}
|
||
|
||
// And the baseline reaches it the same way a button press does: a
|
||
// file stored sideways is a turned frame whether or not it was edited.
|
||
let mut sideways = Framing::new();
|
||
sideways.set_baseline(dr_types::Orientation::from_exif(6));
|
||
sideways.set_param(ANGLE, 5.0);
|
||
assert!(sideways
|
||
.wgsl_prologue()
|
||
.contains("f32(src_dims.y) / f32(src_dims.x)"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
|
||
// `p` is scaled by the source aspect, so a permutation that exchanges
|
||
// the axes has to undo and reapply it. Without that a 90° turn on a
|
||
// 3:2 frame comes out stretched.
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(1);
|
||
assert!(f.wgsl_prologue().contains("aspect.x"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_structure_key_ignores_magnitudes() {
|
||
// What the pipeline cache depends on: dragging the straighten slider
|
||
// or the crop handles must not recompile.
|
||
let mut a = Framing::new();
|
||
a.set_param(ANGLE, 1.0);
|
||
let mut b = Framing::new();
|
||
b.set_param(ANGLE, 4.0);
|
||
assert_eq!(a.structure_key(), b.structure_key());
|
||
assert_eq!(a.wgsl_prologue(), b.wgsl_prologue());
|
||
assert_ne!(a.uniforms(), b.uniforms());
|
||
|
||
let mut c = Framing::new();
|
||
c.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let mut d = Framing::new();
|
||
d.set_crop(CropRect {
|
||
x: 0.2,
|
||
y: 0.2,
|
||
width: 0.4,
|
||
height: 0.4,
|
||
});
|
||
assert_eq!(c.structure_key(), d.structure_key());
|
||
assert_eq!(c.wgsl_prologue(), d.wgsl_prologue());
|
||
}
|
||
|
||
#[test]
|
||
fn different_transforms_take_different_structure_keys() {
|
||
// The other half of the cache contract: framing that generates
|
||
// different code must not reuse another's pipeline.
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
seen.insert(Framing::new().structure_key());
|
||
|
||
let mut cropped = Framing::new();
|
||
cropped.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
assert!(seen.insert(cropped.structure_key()));
|
||
|
||
let mut angled = Framing::new();
|
||
angled.set_param(ANGLE, 2.0);
|
||
assert!(seen.insert(angled.structure_key()));
|
||
|
||
let mut flipped = Framing::new();
|
||
flipped.set_param(FLIP_H, 1.0);
|
||
assert!(seen.insert(flipped.structure_key()));
|
||
|
||
for turns in 1..=3 {
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(turns);
|
||
assert!(seen.insert(f.structure_key()), "{turns} quarter turns");
|
||
}
|
||
}
|
||
|
||
// --- the aspect lock and the safe-area fit ---------------------------
|
||
|
||
/// A crop's shape in output pixels, which is the thing an aspect ratio is
|
||
/// about — the rect's own numbers are fractions of a frame that is not
|
||
/// square, so they are not comparable to `3.0 / 2.0` on their own.
|
||
fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 {
|
||
(c.width * w as f32) / (c.height * h as f32)
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() {
|
||
// The whole of why `with_aspect` takes the frame size. On a 3:2
|
||
// photograph a square crop is *not* a square in fractions, and a
|
||
// version that skipped the conversion would pass every test written
|
||
// on a square frame.
|
||
let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||
assert!(
|
||
(pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4,
|
||
"expected a square in pixels, got {c:?}"
|
||
);
|
||
assert!(
|
||
(c.width - c.height).abs() > 0.1,
|
||
"a square on a 3:2 frame must not be square in fractions: {c:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_offered_ratio_comes_back_at_the_ratio_asked_for() {
|
||
for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] {
|
||
let want = rw as f32 / rh as f32;
|
||
for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||
let c = CropRect {
|
||
x: 0.2,
|
||
y: 0.3,
|
||
width: 0.4,
|
||
height: 0.25,
|
||
}
|
||
.with_aspect(fw, fh, want, (0.5, 0.5));
|
||
let got = pixel_ratio(c, fw, fh);
|
||
assert!(
|
||
(got / want - 1.0).abs() < 1e-3,
|
||
"{rw}:{rh} on {fw}x{fh} came back as {got}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() {
|
||
// The reason the fit is a scale rather than a clamp: clamping one
|
||
// axis at the edge would hold the rect inside at the cost of the
|
||
// ratio, which is the one property the caller asked for.
|
||
for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] {
|
||
for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] {
|
||
let c = CropRect {
|
||
x: 0.05,
|
||
y: 0.9,
|
||
width: 0.9,
|
||
height: 0.09,
|
||
}
|
||
.with_aspect(6000, 4000, rw as f32 / rh as f32, anchor);
|
||
assert!(
|
||
c.x >= -1e-5
|
||
&& c.y >= -1e-5
|
||
&& c.x + c.width <= 1.0 + 1e-5
|
||
&& c.y + c.height <= 1.0 + 1e-5,
|
||
"{rw}:{rh} at {anchor:?} left the frame: {c:?}"
|
||
);
|
||
let got = pixel_ratio(c, 6000, 4000);
|
||
let want = rw as f32 / rh as f32;
|
||
assert!(
|
||
(got / want - 1.0).abs() < 1e-3,
|
||
"{rw}:{rh} at {anchor:?} lost its ratio: {got}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_anchor_corner_is_the_one_that_does_not_move() {
|
||
// What makes a corner drag feel like a corner drag: the opposite
|
||
// corner stays nailed down while the held one shapes the rect.
|
||
let start = CropRect {
|
||
x: 0.2,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.3,
|
||
};
|
||
// Bottom-right held still, top-left free.
|
||
let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0));
|
||
assert!(
|
||
(c.x + c.width - (start.x + start.width)).abs() < 1e-5,
|
||
"{c:?}"
|
||
);
|
||
assert!(
|
||
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
|
||
"{c:?}"
|
||
);
|
||
|
||
// Top-left held still, bottom-right free.
|
||
let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0));
|
||
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
|
||
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
|
||
// Shrinking to fit makes a one-axis drag do nothing at all: the other
|
||
// axis clamps the first straight back, and the handle refuses to move.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.6,
|
||
height: 0.3,
|
||
};
|
||
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0));
|
||
assert!(
|
||
c.width >= start.width - 1e-5,
|
||
"{c:?} narrower than {start:?}"
|
||
);
|
||
assert!(
|
||
c.height >= start.height - 1e-5,
|
||
"{c:?} shorter than {start:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_free_or_nonsense_ratio_leaves_the_rect_alone() {
|
||
// Nothing here should be a failure the caller has to handle: "no
|
||
// ratio" is the ordinary state of the crop tool.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] {
|
||
assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start);
|
||
}
|
||
// A frame with no extent cannot define a ratio either.
|
||
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
|
||
}
|
||
|
||
#[test]
|
||
fn a_rect_already_inside_its_bound_is_left_where_it_is() {
|
||
let bound = CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.8,
|
||
height: 0.8,
|
||
};
|
||
let rect = CropRect {
|
||
x: 0.2,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.3,
|
||
};
|
||
assert_eq!(rect.fitted_into(bound), rect);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_into_a_bound_keeps_the_shape_and_the_side_it_was_on() {
|
||
// A crop placed deliberately off-centre is a decision. Recentring it
|
||
// to solve a problem the user did not have would undo their work.
|
||
let bound = CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
};
|
||
let rect = CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.8,
|
||
height: 0.4,
|
||
};
|
||
let fitted = rect.fitted_into(bound);
|
||
|
||
assert!(
|
||
(fitted.width / fitted.height - rect.width / rect.height).abs() < 1e-4,
|
||
"shape changed: {fitted:?}"
|
||
);
|
||
assert!(
|
||
fitted.x >= bound.x - 1e-5
|
||
&& fitted.y >= bound.y - 1e-5
|
||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||
"{fitted:?} is not inside {bound:?}"
|
||
);
|
||
// It came from the top-left, so it should still be against those
|
||
// edges rather than centred in the bound.
|
||
assert!((fitted.x - bound.x).abs() < 1e-5, "{fitted:?}");
|
||
assert!((fitted.y - bound.y).abs() < 1e-5, "{fitted:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_crop_still_fits_inside_the_straightened_safe_area() {
|
||
// The two new pieces meet here: an angle shrinks the safe area, and a
|
||
// ratio the user locked has to survive being fitted into it.
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, 7.0);
|
||
let bound = f.max_inscribed_crop(6000, 4000);
|
||
|
||
let locked = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||
let fitted = locked.fitted_into(bound);
|
||
|
||
assert!(
|
||
(pixel_ratio(fitted, 6000, 4000) - 1.0).abs() < 1e-3,
|
||
"the lock did not survive the fit: {fitted:?}"
|
||
);
|
||
assert!(
|
||
fitted.x >= bound.x - 1e-5
|
||
&& fitted.y >= bound.y - 1e-5
|
||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||
"{fitted:?} is not inside {bound:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_into_the_whole_frame_is_the_identity() {
|
||
// What makes the straighten correction reversible: at zero degrees the
|
||
// safe area is the whole frame, so recomputing the crop from the
|
||
// user's own rectangle hands it straight back, bit for bit.
|
||
for rect in [
|
||
CropRect::default(),
|
||
CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
},
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 0.5,
|
||
},
|
||
] {
|
||
assert_eq!(rect.fitted_into(CropRect::default()), rect, "{rect:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_crop_recomputed_from_intent_grows_back_as_the_angle_falls() {
|
||
// The whole reason the correction is recomputed rather than
|
||
// accumulated. Taking each angle's fit from the *previous* fit
|
||
// ratchets — the excursion to 20 degrees is never given back — where
|
||
// taking it from the user's own rectangle every time returns it.
|
||
let intended = CropRect::default();
|
||
let bound_at = |deg: f32| {
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, deg);
|
||
f.max_inscribed_crop(6000, 4000)
|
||
};
|
||
|
||
let out = intended.fitted_into(bound_at(20.0));
|
||
let back = intended.fitted_into(bound_at(3.0));
|
||
assert!(
|
||
back.width > out.width && back.height > out.height,
|
||
"3 degrees should give more back than 20 took: {out:?} -> {back:?}"
|
||
);
|
||
|
||
// And all the way home.
|
||
assert_eq!(intended.fitted_into(bound_at(0.0)), intended);
|
||
|
||
// The ratcheting version, for contrast: chaining the fits never
|
||
// recovers, which is the behaviour this arrangement exists to avoid.
|
||
let chained = out.fitted_into(bound_at(3.0));
|
||
assert!(
|
||
chained.width <= out.width + 1e-6,
|
||
"a chained fit must not grow — that is the bug being pinned"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parameters_round_trip() {
|
||
let mut f = Framing::new();
|
||
for (id, v) in [
|
||
(ANGLE, 2.5),
|
||
(ROTATION, 2.0),
|
||
(FLIP_H, 1.0),
|
||
(FLIP_V, 1.0),
|
||
(CROP_X, 0.1),
|
||
(CROP_Y, 0.2),
|
||
(CROP_W, 0.5),
|
||
(CROP_H, 0.4),
|
||
] {
|
||
f.set_param(id, v);
|
||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_default_leaves_the_stage_neutral() {
|
||
// The same contract the operations honour, checked against the
|
||
// descriptor rather than a literal.
|
||
let mut f = Framing::new();
|
||
for p in &DESCRIPTOR.params {
|
||
f.set_param(p.id, p.default);
|
||
}
|
||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||
}
|
||
|
||
#[test]
|
||
fn every_default_is_within_its_declared_range() {
|
||
for p in &DESCRIPTOR.params {
|
||
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn no_parameter_is_declared_twice() {
|
||
let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect();
|
||
let before = ids.len();
|
||
ids.sort_unstable();
|
||
ids.dedup();
|
||
assert_eq!(before, ids.len(), "framing has a duplicate parameter");
|
||
}
|
||
|
||
#[test]
|
||
fn reset_returns_to_neutral() {
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, 3.0);
|
||
f.rotate_quarters(1);
|
||
f.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
width: 0.3,
|
||
height: 0.3,
|
||
});
|
||
assert!(f.is_active());
|
||
|
||
f.reset();
|
||
assert!(!f.is_active());
|
||
assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue());
|
||
}
|
||
|
||
#[test]
|
||
fn uniforms_carry_the_angle_as_sin_and_cos() {
|
||
// The shader never sees degrees: converting here keeps a trig call
|
||
// out of every pixel.
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, 90.0);
|
||
let u = f.uniforms();
|
||
assert!(
|
||
(u[4] - 1.0).abs() < 1e-6,
|
||
"sin(90°) should be 1, got {}",
|
||
u[4]
|
||
);
|
||
assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]);
|
||
}
|
||
|
||
#[test]
|
||
fn uniforms_are_always_finite() {
|
||
// One NaN in the uniform block blanks every pixel.
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, f32::NAN);
|
||
f.set_param(CROP_W, f32::NAN);
|
||
assert!(
|
||
f.uniforms().iter().all(|v| v.is_finite()),
|
||
"{:?}",
|
||
f.uniforms()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_uniform_block_is_vec4_aligned() {
|
||
// Emitted as whole `vec4`s; a size not divisible by four would
|
||
// misalign every operation uniform that follows it.
|
||
assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0);
|
||
assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS);
|
||
}
|
||
|
||
#[test]
|
||
fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() {
|
||
assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn the_inscribed_crop_shrinks_as_the_angle_grows() {
|
||
// Straightening further must cut in further; anything else leaves
|
||
// undefined corners inside the frame.
|
||
let mut small = Framing::new();
|
||
small.set_param(ANGLE, 2.0);
|
||
let mut large = Framing::new();
|
||
large.set_param(ANGLE, 10.0);
|
||
|
||
let a = small.max_inscribed_crop(6000, 4000);
|
||
let b = large.max_inscribed_crop(6000, 4000);
|
||
assert!(a.width > b.width, "{} should exceed {}", a.width, b.width);
|
||
assert!(a.width < 1.0, "a rotated frame cannot keep its full width");
|
||
}
|
||
|
||
#[test]
|
||
fn the_inscribed_crop_is_centred_and_inside_the_frame() {
|
||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] {
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, angle);
|
||
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||
let c = f.max_inscribed_crop(w, h);
|
||
assert!(
|
||
c.width > 0.0 && c.height > 0.0,
|
||
"{angle}° on {w}x{h}: {c:?} is degenerate"
|
||
);
|
||
assert!(
|
||
c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4,
|
||
"{angle}° on {w}x{h}: {c:?} extends past the frame"
|
||
);
|
||
assert!(
|
||
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4,
|
||
"{angle}° on {w}x{h}: {c:?} is not centred"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_inscribed_crop_contains_no_undefined_area() {
|
||
// The property the derivation exists for, checked directly: every
|
||
// corner of the inscribed rect, mapped through the same transform the
|
||
// shader applies, must land inside the source.
|
||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] {
|
||
let mut f = Framing::new();
|
||
f.set_param(ANGLE, angle);
|
||
let (w, h) = (6000.0f32, 4000.0f32);
|
||
let c = f.max_inscribed_crop(6000, 4000);
|
||
|
||
let rad = angle * PI / 180.0;
|
||
let (sn, cs) = (rad.sin(), rad.cos());
|
||
let aspect = w / h;
|
||
|
||
for (fx, fy) in [
|
||
(c.x, c.y),
|
||
(c.x + c.width, c.y),
|
||
(c.x, c.y + c.height),
|
||
(c.x + c.width, c.y + c.height),
|
||
] {
|
||
let (px, py) = ((fx - 0.5) * aspect, fy - 0.5);
|
||
let (rx, ry) = (px * cs - py * sn, px * sn + py * cs);
|
||
let (ux, uy) = (rx / aspect + 0.5, ry + 0.5);
|
||
assert!(
|
||
(-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy),
|
||
"{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- the CPU coordinate map (source_at / output_at) -------------------
|
||
//
|
||
// These matter because the map has no other check on it. The shader's
|
||
// version is verified by the picture looking right; this one is read by
|
||
// hit-testing, where being wrong means a handle that grabs nothing and
|
||
// nothing on screen says why.
|
||
|
||
/// A 3:2 frame. Square would hide every aspect fault in here.
|
||
const SRC: (u32, u32) = (600, 400);
|
||
|
||
fn close(a: (f32, f32), b: (f32, f32), what: &str) {
|
||
assert!(
|
||
(a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4,
|
||
"{what}: {a:?} != {b:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unedited_frame_maps_an_output_point_to_itself() {
|
||
// The neutral prologue is `uv_src = uv`, and a map that quietly
|
||
// introduced an aspect factor here would put every mask a little off
|
||
// on every unedited photograph — the case that is never looked at
|
||
// twice.
|
||
let f = Framing::new();
|
||
for out in [(0.0, 0.0), (0.5, 0.5), (0.25, 0.8), (1.0, 1.0)] {
|
||
close(f.source_at(out, SRC.0, SRC.1), out, "neutral");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_map_round_trips_through_every_transform_at_once() {
|
||
// Handles are drawn with `output_at` and dragged with `source_at`, so
|
||
// a discrepancy between them is a handle that jumps away from the
|
||
// pointer on the first press. Every stage is on, because the faults
|
||
// that survive are the ones only a composition exposes — an aspect
|
||
// applied on one leg and not the other cancels under a bare rotation.
|
||
for turns in 0..4 {
|
||
let mut f = Framing::new();
|
||
f.set_crop(CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.6,
|
||
height: 0.5,
|
||
});
|
||
f.set_view(CropRect {
|
||
x: 0.3,
|
||
y: 0.25,
|
||
width: 0.4,
|
||
height: 0.4,
|
||
});
|
||
f.set_param(ANGLE, -7.5);
|
||
f.rotate_quarters(turns);
|
||
f.set_param(FLIP_H, 1.0);
|
||
f.set_param(FLIP_V, 1.0);
|
||
|
||
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
|
||
let src = f.source_at(out, SRC.0, SRC.1);
|
||
close(f.output_at(src, SRC.0, SRC.1), out, "round trip");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_stored_orientation_is_part_of_the_map() {
|
||
// The baseline reaches the prologue through `effective`, so it has to
|
||
// reach this the same way. A portrait frame the camera stored sideways
|
||
// is the common case, and a map that ignored the tag would place every
|
||
// handle on a photograph that is not the one on screen.
|
||
let mut f = Framing::new();
|
||
f.set_baseline(dr_types::Orientation {
|
||
quarter_turns: 1,
|
||
flip_h: false,
|
||
flip_v: false,
|
||
});
|
||
|
||
// The output's top-left comes from the source's bottom-left under a
|
||
// clockwise quarter turn.
|
||
close(f.source_at((0.0, 0.0), SRC.0, SRC.1), (0.0, 1.0), "turned");
|
||
close(
|
||
f.output_at((0.0, 1.0), SRC.0, SRC.1),
|
||
(0.0, 0.0),
|
||
"turned back",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_in_narrows_what_an_output_point_reaches() {
|
||
// The property the handles depend on: the same place on screen is a
|
||
// *different* source point once the view moves, so a handle drawn from
|
||
// stored geometry has to be re-placed on every frame of a pan. If this
|
||
// were independent of the view the handles would sit still while the
|
||
// photograph slid under them.
|
||
let mut f = Framing::new();
|
||
let wide = f.source_at((0.25, 0.25), SRC.0, SRC.1);
|
||
|
||
f.set_view(CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let close_in = f.source_at((0.25, 0.25), SRC.0, SRC.1);
|
||
|
||
assert!(close_in.0 > wide.0 && close_in.1 > wide.1, "{close_in:?}");
|
||
close(close_in, (0.375, 0.375), "zoomed");
|
||
}
|
||
|
||
#[test]
|
||
fn the_cpu_map_matches_the_prologue_step_for_step() {
|
||
// The two are the same function written twice, and nothing but this
|
||
// stops them drifting apart. It checks the *shape* — that the
|
||
// permutation the prologue emits for each turn is the one implemented
|
||
// above — because the alternative is running WGSL in a unit test.
|
||
let mut f = Framing::new();
|
||
f.rotate_quarters(1);
|
||
assert!(
|
||
f.wgsl_prologue()
|
||
.contains("p = vec2<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, false).contains("p / aspect + vec2<f32>(0.5)"),
|
||
"the sampler's return to texture coordinates moved"
|
||
);
|
||
}
|
||
}
|