Files
DarkRoom/core/dr-pipeline/src/framing.rs
T
dtourolle 1bc04870c3 Let a crop be trimmed from one edge
Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
2026-09-29 21:31:27 -04:00

3050 lines
118 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! # Perspective sits inside framing (FR-DEV-20)
//!
//! A keystone correction is composition too — straightening converging
//! verticals reframes the photograph — so it is a step *of* framing rather
//! than a stage beside it, and inherits framing's `Compose` attribute, its
//! place in the sidecar and its exclusion from a default paste. Expanded, the
//! chain reads:
//!
//! ```text
//! output pixel → crop → straighten → perspective → orientation → warp (lens) → sample
//! ```
//!
//! After the straightening, because the angle is a nudge applied to the
//! corrected picture: the verticals are made parallel and *then* the whole is
//! levelled. Before the stored orientation, because "vertical" means vertical
//! in the photograph as it is shown — a portrait frame the camera stored on
//! its side must converge along its displayed height, not along the sensor's
//! rows. And before the lens warp, which still sees the whole frame it
//! corrects, for the reason given above.
//!
//! The correction maps the output frame onto a trapezoid **inside** the
//! source rather than pulling the source edges in. So a keystone on its own
//! never exposes an empty corner, and the crop the user drew is still valid
//! after it; only in combination with a straightening angle does the
//! inscribed crop have anything to account for — see
//! [`Framing::max_inscribed_crop`].
//!
//! # 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");
/// TRACES: FR-DEV-20
/// Vertical keystone. Positive spreads the top of the frame — the correction
/// for a building photographed looking up, whose verticals lean together.
pub const KEYSTONE_V: ParamId = ParamId("keystone_v");
/// TRACES: FR-DEV-20
/// Horizontal keystone. Positive spreads the right-hand side of the frame.
pub const KEYSTONE_H: ParamId = ParamId("keystone_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 keystone sliders' travel either way.
///
/// A plain amount rather than degrees of tilt: the angle a camera was tilted
/// by depends on a focal length the correction does not know, and a number
/// that claimed to be one would be wrong for every lens but one.
pub const MAX_KEYSTONE: f32 = 100.0;
/// How far a full keystone narrows the far edge of the frame, as a fraction
/// of its width: at `MAX_KEYSTONE` the source trapezoid's short side is half
/// its long one.
///
/// Enough for a tall building from its own pavement, and short of the point
/// where the stretched edge is so magnified that the correction reads as a
/// fault of its own.
const KEYSTONE_REACH: f64 = 0.5;
/// 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; 10] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, KEYSTONE_V, KEYSTONE_H, 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),
// TRACES: FR-DEV-20
// Perspective. Last so every sidecar written before these existed
// reads exactly as it did: a missing parameter is its default, and
// the default is no correction.
ParamDescriptor::scalar(
"keystone_v",
"param.keystone_v",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"keystone_h",
"param.keystone_h",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
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, `(0.0, 0.5)` while
/// the right-hand edge is dragged, 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.
/// The exception is an edge: see the note in the body.
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;
// An anchor in the middle of one side is an *edge* being dragged, and
// then the axis across that edge leads: it is the only one the user
// moved. Growing the short axis instead would take the other side
// for the leader whenever the edge went inward, and the edge would be
// pushed straight back out — a handle that only ever grows the crop.
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
(rect.height * r, rect.height)
} else if ay == 0.5 && ax != 0.5 {
(rect.width, rect.width / r)
} else {
let w = rect.width.max(rect.height * r);
(w, 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-20
/// A plane projective map, row-major, acting on `(x, y, 1)`.
///
/// Held in `f64` because it is built by solving for four corners and then
/// inverted for [`Framing::output_at`]; the shader gets `f32` copies of the
/// forward map only.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Homography([[f64; 3]; 3]);
impl Homography {
/// The map taking the square `[-0.5, 0.5]²` onto the quadrilateral whose
/// corners are `q`, listed top-left, top-right, bottom-right, bottom-left.
///
/// Heckbert's closed form for the unit square, composed with the shift
/// from the centred square onto it.
fn square_to_quad(q: [(f64, f64); 4]) -> Self {
let [(x0, y0), (x1, y1), (x2, y2), (x3, y3)] = q;
let (dx1, dx2, dx3) = (x1 - x2, x3 - x2, x0 - x1 + x2 - x3);
let (dy1, dy2, dy3) = (y1 - y2, y3 - y2, y0 - y1 + y2 - y3);
let den = dx1 * dy2 - dx2 * dy1;
let (g, h) = if den.abs() < 1e-12 {
(0.0, 0.0)
} else {
((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den)
};
// Unit square (u, v) -> quad.
let unit = [
[x1 - x0 + g * x1, x3 - x0 + h * x3, x0],
[y1 - y0 + g * y1, y3 - y0 + h * y3, y0],
[g, h, 1.0],
];
// Centred square -> unit square is `u = x + 0.5`, so fold the shift
// into the constant column.
let mut m = unit;
for row in &mut m {
row[2] += 0.5 * (row[0] + row[1]);
}
Self(m)
}
/// Where `(x, y)` lands, or `None` past the line the map sends to
/// infinity — a point with no image, which the caller treats as outside
/// the source.
fn apply(&self, (x, y): (f64, f64)) -> Option<(f64, f64)> {
let m = &self.0;
let w = m[2][0] * x + m[2][1] * y + m[2][2];
if w <= 1e-9 {
return None;
}
Some((
(m[0][0] * x + m[0][1] * y + m[0][2]) / w,
(m[1][0] * x + m[1][1] * y + m[1][2]) / w,
))
}
/// The inverse map, by the adjugate. Scale is irrelevant to a projective
/// map, so the determinant is only divided out to keep `w` positive and
/// near one — which is what [`Self::apply`]'s horizon test relies on.
fn inverse(&self) -> Self {
let m = &self.0;
let c = |r0: usize, c0: usize, r1: usize, c1: usize| {
m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]
};
let adj = [
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
];
let det = m[0][0] * adj[0][0] + m[0][1] * adj[1][0] + m[0][2] * adj[2][0];
let det = if det.abs() < 1e-12 { 1.0 } else { det };
let mut inv = adj;
for row in &mut inv {
for v in row.iter_mut() {
*v /= det;
}
}
Self(inv)
}
}
/// 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-20
/// Vertical and horizontal keystone, each `-MAX_KEYSTONE..=MAX_KEYSTONE`.
keystone_v: f32,
keystone_h: f32,
/// 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,
keystone_v: 0.0,
keystone_h: 0.0,
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 ten parameters are ten 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 ten sliders — tedious, but complete,
/// which is the guarantee the whole hint mechanism rests on.
///
/// The widget owns **all ten** 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)
}
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, as the sliders show them.
pub fn keystone(&self) -> (f32, f32) {
(self.keystone_v, self.keystone_h)
}
/// Whether a perspective correction is applied at all.
pub fn has_keystone(&self) -> bool {
self.keystone_v != 0.0 || self.keystone_h != 0.0
}
/// TRACES: FR-DEV-20
/// The perspective map, from the straightened output frame to the upright
/// source frame, both measured as the centred square `[-0.5, 0.5]²`.
///
/// **Measured in fractions of the frame, not in the aspect-scaled space
/// the rest of the prologue works in**, so the map is the same for every
/// frame shape and the uniforms need no image size. The prologue divides
/// `p.x` by the frame's aspect on the way in and multiplies it back on
/// the way out.
///
/// The output frame's corners go to a trapezoid inside the source: a
/// positive vertical keystone brings the top corners in, so the top of
/// the source is spread across the full width of the output and lines
/// that converged upward come out parallel. Nothing is ever mapped from
/// outside the source, which is why a keystone alone needs no crop.
fn keystone_map(&self) -> Option<Homography> {
if !self.has_keystone() {
return None;
}
let amount = |v: f32| f64::from(v / MAX_KEYSTONE).clamp(-1.0, 1.0) * KEYSTONE_REACH;
let (tv, th) = (amount(self.keystone_v), amount(self.keystone_h));
// How much of each edge survives: the top and bottom rows' widths,
// the left and right columns' heights.
let top = 1.0 - tv.max(0.0);
let bottom = 1.0 + tv.min(0.0);
let right = 1.0 - th.max(0.0);
let left = 1.0 + th.min(0.0);
Some(Homography::square_to_quad([
(-0.5 * top, -0.5 * left),
(0.5 * top, -0.5 * right),
(0.5 * bottom, 0.5 * right),
(-0.5 * bottom, 0.5 * left),
]))
}
/// 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
|| self.has_keystone()
// 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.has_keystone()
|| 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 {
// A keystone stretches the frame by a different amount at every row,
// so it lands between pixels everywhere but on its centre line.
self.angle != 0.0 || self.has_keystone()
}
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()
}
KEYSTONE_V => self.keystone_v = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
KEYSTONE_H => self.keystone_h = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
_ => 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,
KEYSTONE_V => self.keystone_v,
KEYSTONE_H => self.keystone_h,
_ => 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.
///
/// TRACES: FR-DEV-20
/// **With a keystone the closed form no longer applies**: the area with a
/// source pixel behind it is the source rectangle pulled back through the
/// perspective map and then turned, a quadrilateral no textbook result
/// describes. That case is searched instead — see
/// [`Self::inscribed_by_search`]. A keystone alone never needs a crop, so
/// the search returns the whole frame for it, exactly.
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
if width == 0 || height == 0 {
return CropRect::default();
}
if self.has_keystone() {
return self.inscribed_by_search(width, height);
}
if self.angle == 0.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-20
/// The largest centred crop with a source pixel behind every point, found
/// by search rather than by formula.
///
/// The area that has a source pixel behind it is convex — the source
/// rectangle pulled back through a projective map whose horizon lies
/// outside it, then turned — and a rectangle lies inside a convex region
/// exactly when its four corners do. For a given width the tallest
/// rectangle that fits is therefore found by bisection, and the area
/// `width × tallest(width)` is unimodal in the width (a positive concave
/// function times a line), so a golden-section search finds the best
/// width. Every rectangle returned has been tested corner by corner, so
/// the answer errs inside, never outside.
///
/// A few hundred corner tests, on a gesture's release, is nothing next to
/// the render that follows it.
fn inscribed_by_search(&self, width: u32, height: u32) -> CropRect {
let (w, h) = if self.swaps_axes() {
(f64::from(height), f64::from(width))
} else {
(f64::from(width), f64::from(height))
};
let fa = w / h;
let rad = f64::from(self.angle).to_radians();
let (sn, cs) = (rad.sin(), rad.cos());
let map = self.keystone_map();
// Whether the output point `(fx, fy)`, in fractions of the frame from
// its centre, has a source pixel behind it. The prologue's steps, in
// its order, stopping short of the turns: those are a permutation of
// the frame and cannot move a point across its edge.
let defined = |fx: f64, fy: f64| {
let p = (fx * fa, fy);
let q = (p.0 * cs - p.1 * sn, p.0 * sn + p.1 * cs);
let n = (q.0 / fa, q.1);
let src = match &map {
Some(m) => m.apply(n),
None => Some(n),
};
const EDGE: f64 = 0.5 + 1e-9;
src.is_some_and(|(x, y)| x.abs() <= EDGE && y.abs() <= EDGE)
};
let fits = |hw: f64, hh: f64| {
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.all(|(sx, sy)| defined(sx * hw, sy * hh))
};
if fits(0.5, 0.5) {
return CropRect::default();
}
// Half the tallest height that fits at half-width `hw`.
let tallest = |hw: f64| {
if fits(hw, 0.5) {
return 0.5;
}
if !fits(hw, 0.0) {
return 0.0;
}
let (mut lo, mut hi) = (0.0, 0.5);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if fits(hw, mid) {
lo = mid;
} else {
hi = mid;
}
}
lo
};
let area = |hw: f64| hw * tallest(hw);
let ratio = (5.0_f64.sqrt() - 1.0) * 0.5;
let (mut a, mut b) = (0.0, 0.5);
let mut c = b - ratio * (b - a);
let mut d = a + ratio * (b - a);
let (mut fc, mut fd) = (area(c), area(d));
for _ in 0..48 {
if fc < fd {
a = c;
c = d;
fc = fd;
d = a + ratio * (b - a);
fd = area(d);
} else {
b = d;
d = c;
fd = fc;
c = b - ratio * (b - a);
fc = area(c);
}
}
let hw = 0.5 * (a + b);
let hh = tallest(hw);
// Tested at `hw` as returned, so a width that the search's last step
// nudged past the boundary cannot come back with a height that no
// longer fits it.
let (hw, hh) = if hh > 0.0 && fits(hw, hh) {
(hw, hh)
} else {
(c.min(d), tallest(c.min(d)))
};
let fw = (2.0 * hw) as f32;
let fh = (2.0 * hh) as f32;
let fw = fw.clamp(CropRect::MIN_EXTENT, 1.0);
let fh = fh.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);
}
if let Some(m) = self.keystone_map() {
p = match m.apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
// Beyond the map's horizon: no source point at all, reported
// as one far outside the frame rather than as a NaN.
None => (1e6, 1e6),
};
}
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 let Some(m) = self.keystone_map() {
p = match m.inverse().apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
None => (1e6, 1e6),
};
}
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();
// The perspective map by columns, so the prologue can apply it as
// three multiply-adds. The identity when there is none: the slots
// exist either way and the prologue does not read them.
let m = self
.keystone_map()
.unwrap_or(Homography([
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]))
.0;
let col = |c: usize| [m[0][c] as f32, m[1][c] as f32, m[2][c] as f32, 0.0];
let [c0, c1, c2] = [col(0), col(1), col(2)];
[
rect.x,
rect.y,
rect.width,
rect.height,
rad.sin(),
rad.cos(),
0.0,
0.0,
c0[0],
c0[1],
c0[2],
c0[3],
c1[0],
c1[1],
c1[2],
c1[3],
c2[0],
c2[1],
c2[2],
c2[3],
]
}
/// 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 tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
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 tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
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,
);
",
);
}
if self.has_keystone() {
// TRACES: FR-DEV-20
// After the straightening and before the turns, so the keystone
// acts on the photograph as it is shown. The map is measured in
// fractions of the frame (see `Framing::keystone_map`), hence the
// aspect divided out and put back. A point past the map's horizon
// has no source at all and is sent far outside it, where the
// sampler's bounds test renders it void.
s.push_str(
"
// Perspective: the straightened frame onto a trapezoid of the source.
let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);
let key_h = u.keystone_c0.xyz * key_n.x + u.keystone_c1.xyz * key_n.y + u.keystone_c2.xyz;
p = select(
vec2<f32>(1.0e6),
vec2<f32>(key_h.x / key_h.z * frame_aspect.x, key_h.y / key_h.z),
key_h.z > 1.0e-6,
);
",
);
}
// 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
| u64::from(self.has_keystone()) << 7
}
}
/// Floats the framing block occupies in the generated uniform struct.
///
/// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
/// perspective map's three columns, each padded.
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
#[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_edge_leads_and_the_far_side_stays_put() {
// An edge dragged inward under a lock must narrow the crop. With the
// short axis leading, the untouched height would win and push the
// edge straight back out.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.6,
};
// Right edge held, dragged in: the left side and the vertical
// centre stay, the width is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
assert!(
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
"{c:?}"
);
// Top edge held: the bottom and the horizontal centre stay, the
// height is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
assert!(
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).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),
(KEYSTONE_V, 35.0),
(KEYSTONE_H, -20.0),
] {
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);
f.set_param(KEYSTONE_V, 60.0);
f.set_param(KEYSTONE_H, -25.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"
);
// The perspective step: the map applied in fractions of the frame,
// which is what `source_at` divides the aspect out for.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
let prologue = f.wgsl_prologue();
assert!(
prologue.contains("let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);")
&& prologue.contains("key_h.x / key_h.z * frame_aspect.x"),
"the perspective step moved; `source_at` must move with it"
);
// After the straightening and before the turns, as `source_at` has it.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
f.set_param(ANGLE, 3.0);
f.rotate_quarters(1);
let prologue = f.wgsl_prologue();
let straighten = prologue.find("// Straighten").unwrap();
let keystone = prologue.find("// Perspective").unwrap();
let turn = prologue.find("90° clockwise").unwrap();
assert!(straighten < keystone && keystone < turn, "{prologue}");
// 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"
);
}
// ---- perspective (FR-DEV-20) -----------------------------------------
/// A grid over the whole output frame, edges included.
fn grid() -> impl Iterator<Item = (f32, f32)> {
(0..=10).flat_map(|j| (0..=10).map(move |i| (i as f32 / 10.0, j as f32 / 10.0)))
}
fn inside(p: (f32, f32)) -> bool {
(-1e-4..=1.0 + 1e-4).contains(&p.0) && (-1e-4..=1.0 + 1e-4).contains(&p.1)
}
#[test]
fn a_keystone_is_an_edit_and_a_resample() {
let mut f = Framing::new();
let neutral = f.structure_key();
f.set_param(KEYSTONE_V, 30.0);
assert!(f.is_active());
assert!(f.edits_image(), "a keystone must light the modified dot");
assert!(f.needs_interpolation());
assert_ne!(f.structure_key(), neutral);
assert!(f.wgsl_prologue().contains("u.keystone_c0"));
// Neither the output size nor the crop moves: the frame is reshaped
// inside itself, so what the user cropped stays cropped.
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
assert!(f.crop().is_full());
}
#[test]
fn the_keystone_magnitude_does_not_reach_the_structure_key() {
// Dragging the slider is a uniform upload, never a shader build.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 10.0);
let key = f.structure_key();
for (v, h) in [(80.0, 0.0), (-45.0, 30.0), (1.0, -100.0)] {
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
assert_eq!(f.structure_key(), key, "{v}/{h} forced a recompile");
}
}
#[test]
fn the_keystone_is_clamped_to_its_travel() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 1e9);
f.set_param(KEYSTONE_H, f32::NAN);
assert_eq!(f.keystone(), (MAX_KEYSTONE, 0.0));
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
#[test]
fn a_keystone_alone_never_reaches_outside_the_source() {
// The design decision the crop relies on: the output frame is mapped
// onto a trapezoid *inside* the source, so no corner goes empty and
// a crop drawn before the keystone is still a crop of the picture.
for (v, h) in [
(100.0, 0.0),
(-100.0, 0.0),
(0.0, 100.0),
(0.0, -100.0),
(100.0, 100.0),
(-100.0, 100.0),
(37.0, -64.0),
] {
for turns in 0..4 {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{v}/{h}, {turns} turn(s): {out:?} -> {src:?}");
}
assert!(f.max_inscribed_crop(SRC.0, SRC.1).is_full());
}
}
}
#[test]
fn a_vertical_keystone_makes_upward_converging_lines_parallel() {
// What the control is for. Output columns are straight verticals;
// with a positive keystone each must come from a straight source line
// that leans in toward the centre as it rises — the shape a building
// has when photographed looking up.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 60.0);
for x in [0.1f32, 0.3, 0.7, 0.9] {
let bottom = f.source_at((x, 1.0), SRC.0, SRC.1);
let middle = f.source_at((x, 0.5), SRC.0, SRC.1);
let top = f.source_at((x, 0.0), SRC.0, SRC.1);
// Straight: the middle sits on the line through the two ends.
let cross = (top.0 - bottom.0) * (middle.1 - bottom.1)
- (top.1 - bottom.1) * (middle.0 - bottom.0);
assert!(cross.abs() < 1e-4, "column {x} is not a straight line");
// Leaning in: the top is nearer the centre than the bottom.
assert!(
(top.0 - 0.5).abs() < (bottom.0 - 0.5).abs(),
"column {x}: top {top:?} is not inside bottom {bottom:?}"
);
}
// The bottom row is left where it was; the top row is the one spread.
close(
f.source_at((0.0, 1.0), SRC.0, SRC.1),
(0.0, 1.0),
"bottom-left",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.15, 0.0),
"top-left",
);
}
#[test]
fn a_horizontal_keystone_spreads_the_right_hand_side() {
let mut f = Framing::new();
f.set_param(KEYSTONE_H, 100.0);
// The right-hand column comes from half the source's height.
close(
f.source_at((1.0, 0.0), SRC.0, SRC.1),
(1.0, 0.25),
"top-right",
);
close(
f.source_at((1.0, 1.0), SRC.0, SRC.1),
(1.0, 0.75),
"bottom-right",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.0, 0.0),
"top-left",
);
}
#[test]
fn the_keystone_acts_on_the_frame_as_shown() {
// A portrait frame the camera stored on its side: "vertical" is the
// frame's displayed height, so the same keystone must move the same
// *displayed* points whatever the file's stored orientation.
let mut upright = Framing::new();
upright.set_param(KEYSTONE_V, 50.0);
let mut sideways = Framing::new();
sideways.set_baseline(dr_types::Orientation::from_exif(6));
sideways.set_param(KEYSTONE_V, 50.0);
// Compare in the displayed frame: map the sideways result back
// through the orientation alone.
let mut turn_only = Framing::new();
turn_only.set_baseline(dr_types::Orientation::from_exif(6));
for out in grid() {
let a = upright.source_at(out, SRC.1, SRC.0);
let b = turn_only.output_at(sideways.source_at(out, SRC.0, SRC.1), SRC.0, SRC.1);
close(a, b, "the keystone turned with the file");
}
}
#[test]
fn the_inscribed_crop_accounts_for_the_keystone() {
// Straightening a keystoned frame: the empty area is no longer the
// rotated rectangle's, and the crop must avoid the area that is.
for (angle, v, h) in [
(5.0f32, 50.0f32, 0.0f32),
(-8.0, -70.0, 20.0),
(12.0, 100.0, 100.0),
(2.0, 0.0, -40.0),
] {
for turns in [0, 1] {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(ANGLE, angle);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
assert!(
c.width > 0.3 && c.height > 0.3 && !c.is_full(),
"{angle}°/{v}/{h}: {c:?}"
);
assert!(
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4
&& ((c.y + c.height * 0.5) - 0.5).abs() < 1e-4,
"{c:?} is not centred"
);
// Every point of it, edges included, has a source pixel.
f.set_crop(c);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{angle}°/{v}/{h}: {out:?} -> {src:?}");
}
}
}
}
#[test]
fn the_inscribed_crop_with_a_keystone_is_not_needlessly_small() {
// The search must find the best rectangle, not merely a safe one. A
// tenth larger in either direction has to reach outside the source.
let mut f = Framing::new();
f.set_param(ANGLE, 6.0);
f.set_param(KEYSTONE_V, 60.0);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
for (gw, gh) in [(1.1, 1.0), (1.0, 1.1)] {
let mut g = f;
let (w, h) = (c.width * gw, c.height * gh);
g.set_crop(CropRect {
x: 0.5 - w * 0.5,
y: 0.5 - h * 0.5,
width: w,
height: h,
});
let spills = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
.into_iter()
.any(|out| !inside(g.source_at(out, SRC.0, SRC.1)));
// Either the grown rect spills, or it could not grow at all
// because the crop was already at the frame's edge on that axis.
assert!(
spills
|| (gw > 1.0 && c.width >= 1.0 - 1e-4)
|| (gh > 1.0 && c.height >= 1.0 - 1e-4),
"{c:?} grown by {gw}x{gh} still fits"
);
}
}
#[test]
fn reset_clears_the_keystone() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 30.0);
f.set_param(KEYSTONE_H, -30.0);
f.reset();
assert_eq!(f.keystone(), (0.0, 0.0));
assert!(!f.is_active());
}
}