Build and test / Desktop (Linux) (push) Successful in 2h16m6s
Build and test / Layer separation (push) Successful in 52s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 51s
Build and test / Android (aarch64) (push) Successful in 47m10s
The auto-crop only ever shrank. Straighten to 20 degrees and the corners are cropped away correctly; come back to 3, or all the way to zero, and the crop stays at the size 20 degrees demanded. Nothing on screen explains why the photograph is still small, and the only way back was undo. The cause was that each correction was computed from the previous correction's output, so it accumulated: every angle the slider rested at took its cut and none was ever returned. The fix is to stop accumulating and recompute. The applied crop is now always the user's own rectangle fitted into the current angle's safe area, so as the angle falls and that area opens up the crop grows back — and stops, exactly, at the rectangle they chose. At zero the safe area is the whole frame and the fit is the identity, which is what carries it the last of the way home. There is deliberately no early exit for the upright case now: that exit is precisely what would strand the crop small. **The intent is remembered as a pair, so it repairs itself.** The session keeps `(applied, intended)` — what the correction wrote, and what it was derived from — and trusts the remembered intent only while the graph still holds `applied`. Every other route to the crop leaves something else there: a handle dragged, a ratio chosen, a sidecar loaded, a paste, an undo. That mismatch is the signal the memory is stale, and the current rectangle becomes the new intent. The alternative was a write into this field from each of those paths, which is the kind of bookkeeping that is correct until someone adds a seventh path. Dragging a handle therefore *is* the user choosing, including at a non-zero angle: the correction will not later grow the crop past what they dragged it to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2302 lines
88 KiB
Rust
2302 lines
88 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::Geometry],
|
||
id: ID,
|
||
label: LocalizedKey("op.framing"),
|
||
params: vec![
|
||
// Straightening. Degrees rather than a normalised amount because a
|
||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||
ParamDescriptor::scalar(
|
||
"angle",
|
||
"param.angle",
|
||
-MAX_STRAIGHTEN,
|
||
MAX_STRAIGHTEN,
|
||
0.0,
|
||
Unit::None,
|
||
Scale::Linear,
|
||
2,
|
||
),
|
||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||
// and lossless, and because reorienting a frame is a different
|
||
// gesture from nudging a horizon.
|
||
ParamDescriptor::scalar(
|
||
"rotation",
|
||
"param.rotation",
|
||
0.0,
|
||
3.0,
|
||
0.0,
|
||
Unit::None,
|
||
Scale::Linear,
|
||
0,
|
||
),
|
||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||
// The crop rect, in fractions of the source. Normalised rather than
|
||
// in pixels so a crop survives being applied to a proxy, a full
|
||
// resolution render, or an export at another size — the same reason
|
||
// the viewport renders at display resolution (FR-DSP-1).
|
||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||
],
|
||
})
|
||
});
|
||
|
||
/// A normalised crop rectangle, in fractions of the source image.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct CropRect {
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub width: f32,
|
||
pub height: f32,
|
||
}
|
||
|
||
impl Default for CropRect {
|
||
fn default() -> Self {
|
||
Self {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CropRect {
|
||
/// Smallest crop the rect may be reduced to, as a fraction of the source.
|
||
///
|
||
/// A zero-extent crop produces a zero-sized output texture, which is a
|
||
/// device error rather than a visibly silly image. Bounding it here means
|
||
/// no caller has to defend against it.
|
||
pub const MIN_EXTENT: f32 = 0.01;
|
||
|
||
/// Whether this rect selects the whole image.
|
||
pub fn is_full(&self) -> bool {
|
||
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
|
||
}
|
||
|
||
/// Clamp into the unit square, keeping the rect non-degenerate.
|
||
///
|
||
/// The origin is clamped first and the extent fitted to what remains, so
|
||
/// a rect dragged past an edge slides rather than inverting.
|
||
pub fn normalised(self) -> Self {
|
||
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||
Self {
|
||
x,
|
||
y,
|
||
// `max` before `min`, not `f32::clamp`. With the origin at its
|
||
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
|
||
// an inverted range, which `clamp` panics on rather than
|
||
// resolving. Ordering it this way lets the lower bound win, which
|
||
// is also the answer that keeps the rect non-degenerate.
|
||
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
|
||
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Reshape onto an aspect ratio, holding one point of the rect still.
|
||
///
|
||
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
|
||
/// is what a photographer means by an aspect ratio and is emphatically not
|
||
/// what this rect stores. The rect is in fractions of a frame that is
|
||
/// itself not square, so the ratio it needs is `ratio * height / width` of
|
||
/// that frame. Skipping the conversion yields a "1:1" crop that is square
|
||
/// only on a square photograph, which is the one case nobody tests on.
|
||
///
|
||
/// `anchor` is the point of the rect that stays put, in the rect's own
|
||
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
|
||
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
|
||
/// a ratio is chosen and the composition should stay where it is.
|
||
///
|
||
/// **The rect grows onto the ratio rather than shrinking onto it.** The
|
||
/// axis that is short is extended; the long one is never trimmed. Fitting
|
||
/// inside instead reads as a dead control — dragging a corner outward
|
||
/// along one axis alone would be immediately clamped back by the other,
|
||
/// and the handle would simply refuse to move. The result is then scaled
|
||
/// down, both axes together, only as far as the frame's edge demands.
|
||
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
|
||
let rect = self.normalised();
|
||
let ratio = finite(ratio, 0.0);
|
||
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
|
||
return rect;
|
||
}
|
||
|
||
// Width over height as a fraction of *this* frame, not in pixels.
|
||
let r = ratio * frame_h as f32 / frame_w as f32;
|
||
|
||
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
|
||
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
|
||
// Where the fixed point sits in the frame. Everything below is built
|
||
// outwards from it, which is what makes the anchor mean anything.
|
||
let px = rect.x + ax * rect.width;
|
||
let py = rect.y + ay * rect.height;
|
||
|
||
let mut w = rect.width.max(rect.height * r);
|
||
let mut h = w / r;
|
||
|
||
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||
// frame would break the very ratio this exists to hold.
|
||
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
|
||
if shrink.is_finite() && shrink > 0.0 {
|
||
w *= shrink;
|
||
h *= shrink;
|
||
}
|
||
|
||
Self {
|
||
x: px - ax * w,
|
||
y: py - ay * h,
|
||
width: w,
|
||
height: h,
|
||
}
|
||
.normalised()
|
||
}
|
||
/// TRACES: FR-DEV-3
|
||
/// The largest copy of this rect, at its own shape, sitting inside
|
||
/// `bound`.
|
||
///
|
||
/// Scaled down only as far as `bound` demands and then slid inside it,
|
||
/// rather than replaced by `bound` or centred within it. Both of those
|
||
/// throw away a composition: a crop placed deliberately off-centre is a
|
||
/// decision, and an automatic correction that recentres it has undone the
|
||
/// user's work to fix a problem they did not have.
|
||
pub fn fitted_into(self, bound: Self) -> Self {
|
||
let rect = self.normalised();
|
||
let bound = bound.normalised();
|
||
|
||
let shrink = (bound.width / rect.width)
|
||
.min(bound.height / rect.height)
|
||
.min(1.0);
|
||
let shrink = if shrink.is_finite() && shrink > 0.0 {
|
||
shrink
|
||
} else {
|
||
1.0
|
||
};
|
||
let (w, h) = (rect.width * shrink, rect.height * shrink);
|
||
|
||
// Shrunk about its own centre, so what was framed stays framed — but
|
||
// only when it actually shrank. Routing the untouched case through
|
||
// the same arithmetic moves the origin by a rounding error, and a
|
||
// rect that is already inside its bound must come back *identical*:
|
||
// this is called on every straighten, and a rect that drifts a
|
||
// millionth each time is an edit recorded for no reason.
|
||
let (x, y) = if shrink < 1.0 {
|
||
(
|
||
rect.x + (rect.width - w) * 0.5,
|
||
rect.y + (rect.height - h) * 0.5,
|
||
)
|
||
} else {
|
||
(rect.x, rect.y)
|
||
};
|
||
|
||
Self {
|
||
// `max` on the upper limit for the same reason `normalised`
|
||
// orders its bounds that way: rounding can put the far edge a
|
||
// hair *below* the near one, and `clamp` panics on an inverted
|
||
// range rather than resolving it.
|
||
x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)),
|
||
y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)),
|
||
width: w,
|
||
height: h,
|
||
}
|
||
.normalised()
|
||
}
|
||
}
|
||
|
||
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
|
||
/// extend before it leaves the `0..1` frame.
|
||
///
|
||
/// One axis at a time, and infinite where the anchor is at the far end and the
|
||
/// rect therefore only grows away from that edge.
|
||
fn room(p: f32, a: f32) -> f32 {
|
||
let before = if a > 0.0 { p / a } else { f32::INFINITY };
|
||
let after = if a < 1.0 {
|
||
(1.0 - p) / (1.0 - a)
|
||
} else {
|
||
f32::INFINITY
|
||
};
|
||
before.min(after)
|
||
}
|
||
|
||
/// Replace a non-finite value with a fallback.
|
||
///
|
||
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
|
||
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
|
||
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
|
||
fn finite(v: f32, fallback: f32) -> f32 {
|
||
if v.is_finite() {
|
||
v
|
||
} else {
|
||
fallback
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||
/// Crop, straighten, rotation and flips for one image.
|
||
///
|
||
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
|
||
/// device is recovered by re-composing rather than by re-deriving the edit
|
||
/// (ARCH §6.10).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Framing {
|
||
/// Straightening, in degrees. Positive rotates the image clockwise.
|
||
angle: f32,
|
||
/// Quarter turns clockwise, 0..=3.
|
||
quarter_turns: u8,
|
||
flip_h: bool,
|
||
flip_v: bool,
|
||
/// TRACES: FR-DEV-3h
|
||
/// How the file's pixels were stored, from its EXIF orientation.
|
||
///
|
||
/// **Not an edit**, and this is the whole reason it is a separate field
|
||
/// rather than a starting value for `quarter_turns`. A camera held
|
||
/// sideways stored its rows the way it always does and wrote a tag saying
|
||
/// so; obeying that tag is part of reading the file, not a decision the
|
||
/// user made. Folding it into `quarter_turns` would make every portrait
|
||
/// frame open already-modified, write a rotation into every sidecar, and —
|
||
/// worst — make "reset framing" lay the photograph on its side, since
|
||
/// reset's whole meaning is "back to the file as it is".
|
||
///
|
||
/// So it sits underneath: [`Self::param`] and the sidecar see only the
|
||
/// user's turns, while everything that renders or measures the frame sees
|
||
/// the two composed. It composes exactly, because a quarter turn and two
|
||
/// mirrors form a group of eight that is closed under composition — the
|
||
/// pair always collapses back to one turn and two flags, so the shader
|
||
/// still emits a single permutation and costs nothing for the baseline.
|
||
baseline: dr_types::Orientation,
|
||
crop: CropRect,
|
||
/// Which part of the framed image the viewport is looking at.
|
||
///
|
||
/// **Not an edit.** Zooming changes what you are inspecting, never what
|
||
/// the file becomes: 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) -> 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)`, `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.
|
||
/// 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_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");
|
||
}
|
||
}
|
||
|
||
// --- 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).contains("p / aspect + vec2<f32>(0.5)"),
|
||
"the sampler's return to texture coordinates moved"
|
||
);
|
||
}
|
||
}
|