Files
DarkRoom/core/dr-pipeline/src/framing.rs
T
dtourolle 7421837c8a Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
2026-08-22 10:10:00 +02:00

1595 lines
60 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.
//!
//! # Why sampling changes with the angle
//!
//! At 90° steps and flips, output pixels land exactly on source pixels, so
//! the map is a permutation and an integer `textureLoad` is both correct and
//! lossless. At any other angle it is not, and nearest-neighbour sampling
//! makes a straightened horizon visibly stair-step — the commonest use of
//! this stage, and where the artefact is most obvious. Free angles therefore
//! need interpolation, which is what [`Framing::needs_interpolation`] tells
//! the composer. Paying for it only when the angle demands it keeps the
//! common case exact rather than merely close.
use std::f32::consts::PI;
use std::fmt::Write as _;
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
WidgetDemand, WidgetKind,};
use crate::operation::Affects;
pub const ID: OpId = OpId("framing");
pub const ANGLE: ParamId = ParamId("angle");
pub const ROTATION: ParamId = ParamId("rotation");
pub const FLIP_H: ParamId = ParamId("flip_h");
pub const FLIP_V: ParamId = ParamId("flip_v");
pub const CROP_X: ParamId = ParamId("crop_x");
pub const CROP_Y: ParamId = ParamId("crop_y");
pub const CROP_W: ParamId = ParamId("crop_w");
pub const CROP_H: ParamId = ParamId("crop_h");
/// Widest straightening the control offers, in degrees either way.
///
/// Straightening a horizon is a small correction; a gross reorientation is
/// what the 90° steps are for. Bounding it keeps the slider's travel where
/// the edits actually are.
pub const MAX_STRAIGHTEN: f32 = 45.0;
/// The parameters the framing widget owns — every one of them.
///
/// In the order the widget expects: the rect first, then the angle it is
/// straightened by, then the exact reorientations.
static FRAMING_PARAMS: [ParamId; 8] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
];
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// The shape of the frame, and the only operation that changes the
// output's dimensions.
attributes: &[Attribute::Geometry],
id: ID,
label: LocalizedKey("op.framing"),
params: &[
// Straightening. Degrees rather than a normalised amount because a
// photographer reading "-1.4°" off a horizon knows what it means.
ParamDescriptor::scalar(
"angle",
"param.angle",
-MAX_STRAIGHTEN,
MAX_STRAIGHTEN,
0.0,
Unit::None,
Scale::Linear,
2,
),
// Quarter turns, 0..3. Separate from `angle` because these are exact
// and lossless, and because reorienting a frame is a different
// gesture from nudging a horizon.
ParamDescriptor::scalar(
"rotation",
"param.rotation",
0.0,
3.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::switch("flip_h", "param.flip_h"),
ParamDescriptor::switch("flip_v", "param.flip_v"),
// The crop rect, in fractions of the source. Normalised rather than
// in pixels so a crop survives being applied to a proxy, a full
// resolution render, or an export at another size — the same reason
// the viewport renders at display resolution (FR-DSP-1).
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
],
};
/// A normalised crop rectangle, in fractions of the source image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CropRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Default for CropRect {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
}
}
}
impl CropRect {
/// Smallest crop the rect may be reduced to, as a fraction of the source.
///
/// A zero-extent crop produces a zero-sized output texture, which is a
/// device error rather than a visibly silly image. Bounding it here means
/// no caller has to defend against it.
pub const MIN_EXTENT: f32 = 0.01;
/// Whether this rect selects the whole image.
pub fn is_full(&self) -> bool {
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
}
/// Clamp into the unit square, keeping the rect non-degenerate.
///
/// The origin is clamped first and the extent fitted to what remains, so
/// a rect dragged past an edge slides rather than inverting.
pub fn normalised(self) -> Self {
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
Self {
x,
y,
// `max` before `min`, not `f32::clamp`. With the origin at its
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
// an inverted range, which `clamp` panics on rather than
// resolving. Ordering it this way lets the lower bound win, which
// is also the answer that keeps the rect non-degenerate.
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
}
}
}
/// Replace a non-finite value with a fallback.
///
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
fn finite(v: f32, fallback: f32) -> f32 {
if v.is_finite() {
v
} else {
fallback
}
}
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// Crop, straighten, rotation and flips for one image.
///
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
/// device is recovered by re-composing rather than by re-deriving the edit
/// (ARCH §6.10).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Framing {
/// Straightening, in degrees. Positive rotates the image clockwise.
angle: f32,
/// Quarter turns clockwise, 0..=3.
quarter_turns: u8,
flip_h: bool,
flip_v: bool,
/// TRACES: FR-DEV-3h
/// How the file's pixels were stored, from its EXIF orientation.
///
/// **Not an edit**, and this is the whole reason it is a separate field
/// rather than a starting value for `quarter_turns`. A camera held
/// sideways stored its rows the way it always does and wrote a tag saying
/// so; obeying that tag is part of reading the file, not a decision the
/// user made. Folding it into `quarter_turns` would make every portrait
/// frame open already-modified, write a rotation into every sidecar, and —
/// worst — make "reset framing" lay the photograph on its side, since
/// reset's whole meaning is "back to the file as it is".
///
/// So it sits underneath: [`Self::param`] and the sidecar see only the
/// user's turns, while everything that renders or measures the frame sees
/// the two composed. It composes exactly, because a quarter turn and two
/// mirrors form a group of eight that is closed under composition — the
/// pair always collapses back to one turn and two flags, so the shader
/// still emits a single permutation and costs nothing for the baseline.
baseline: dr_types::Orientation,
crop: CropRect,
/// Which part of the framed image the viewport is looking at.
///
/// **Not an edit.** Zooming changes what you are inspecting, never what
/// the file becomes: it is excluded from [`Self::is_active`], from the
/// structure hash, and from the sidecar, so a zoomed view exports exactly
/// as an unzoomed one does.
///
/// It lives here rather than in the UI because it composes with the crop
/// in the same normalised space — nesting one rect inside the other is a
/// multiply, and doing it here means the shader needs no second rect and
/// no extra uniform slot.
view: CropRect,
}
impl Default for Framing {
fn default() -> Self {
Self {
angle: 0.0,
quarter_turns: 0,
flip_h: false,
flip_v: false,
baseline: dr_types::Orientation::NORMAL,
crop: CropRect::default(),
view: CropRect::default(),
}
}
}
impl Framing {
pub fn new() -> Self {
Self::default()
}
pub fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
/// How framing would like to be presented.
///
/// **This is what stops a frontend having to name this stage.** Rendered
/// generically these eight parameters are eight bad controls: four crop
/// edges the photographer would have to type coordinates into, a "rotate"
/// slider running 0..3, and two switches. Every one of them is a worse
/// control than the gesture it stands for — a crop is dragged on the
/// photograph and a quarter turn is a button.
///
/// Before this existed, the frontend knew that by *checking the operation
/// id* and skipping it, which is precisely the naming ARCH §4.3a forbids:
/// a second frontend would have had to learn the same special case, and
/// nothing in the capability output said why. Now the preference is
/// declared, the demand says what the widget needs, and a frontend that
/// cannot meet it falls back to the eight sliders — tedious, but complete,
/// which is the guarantee the whole hint mechanism rests on.
///
/// The widget owns **all eight** parameters rather than only the rect: a
/// frontend that takes this on is taking on the whole framing control
/// surface, and leaving rotation and the flips behind would scatter them
/// into the generated panel underneath a crop control that already exists.
pub fn presentation(&self) -> Option<Presentation> {
Some(Presentation {
widgets: &[WidgetKind::CropOverlay],
demand: WidgetDemand {
// A crop rect is dragged by its corners; nothing about that
// reduces to one axis at a time.
two_dimensional: true,
// Deliberately false. The handles are large and a crop is
// forgiving — FR-UI-7 has the interaction regions grow to the
// modality, so a thumb is as workable as a mouse.
precise_pointing: false,
},
params: &FRAMING_PARAMS,
})
}
/// What this stage affects, for invalidation scoping (FR-DEV-3d).
pub fn affects(&self) -> Affects {
Affects::Geometry
}
pub fn crop(&self) -> CropRect {
self.crop
}
pub fn set_crop(&mut self, rect: CropRect) {
self.crop = rect.normalised();
}
/// The region of the framed image the viewport shows.
pub fn view(&self) -> CropRect {
self.view
}
/// Look at part of the framed image, in fractions of it.
///
/// The whole unit square is "fit to the viewport"; a smaller rect is
/// zoomed in. Because the render target keeps its size while the sampled
/// region shrinks, zooming *raises* the resolution the pipeline works at
/// rather than magnifying already-rendered pixels — which is what makes a
/// 1:1 inspection show real detail.
pub fn set_view(&mut self, rect: CropRect) {
self.view = rect.normalised();
}
/// Whether the viewport is showing anything other than the whole frame.
pub fn is_zoomed(&self) -> bool {
!self.view.is_full()
}
pub fn angle(&self) -> f32 {
self.angle
}
pub fn quarter_turns(&self) -> u8 {
self.quarter_turns
}
pub fn flips(&self) -> (bool, bool) {
(self.flip_h, self.flip_v)
}
/// Add quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
}
/// How the file stored its pixels — see the field.
pub fn baseline(&self) -> dr_types::Orientation {
self.baseline
}
/// Record the file's EXIF orientation.
///
/// Set once when the image is opened, before any edit is restored. It is
/// deliberately not a `set_param`: the descriptor lists what the user can
/// change, and this is a property of the file.
pub fn set_baseline(&mut self, orientation: dr_types::Orientation) {
self.baseline = orientation;
}
/// The baseline and the user's turns and mirrors, collapsed into one.
///
/// Everything that renders or measures the frame goes through here; only
/// the panel's readout and the sidecar read the user's values raw.
///
/// The composition is the group law, not an addition. Writing a transform
/// as `mirrors ∘ turn`, applying the user's and then the baseline's gives
/// `Db · Rtb · Du · Rtu`, and conjugating `Du` past `Rtb` swaps its two
/// axes when that turn is odd — which is exactly the case that a naive
/// "add the turns, or the flags" gets wrong, and gets wrong silently,
/// since the result is still a valid-looking orientation.
fn effective(&self) -> (u8, bool, bool) {
let b = self.baseline;
// The user's mirrors, seen from the far side of the baseline's turn.
let (ux, uy) = if b.swaps_axes() {
(self.flip_v, self.flip_h)
} else {
(self.flip_h, self.flip_v)
};
(
(b.quarter_turns + self.quarter_turns) % 4,
b.flip_h != ux,
b.flip_v != uy,
)
}
/// Whether this stage currently changes the image.
///
/// The same contract the operations honour: neutral framing contributes
/// nothing to the generated shader, so an uncropped image reads its
/// pixels through the identity map exactly as it did before this existed.
pub fn is_active(&self) -> bool {
let (turns, flip_h, flip_v) = self.effective();
self.angle != 0.0
// Effective, not the user's: a file stored sideways needs the
// prologue emitted even on an untouched image, or it renders
// through the identity map and lies on its side.
|| turns != 0
|| flip_h
|| flip_v
|| !self.crop.is_full()
// Zoom is not an edit, but it *is* a coordinate map: without the
// prologue the shader samples the whole frame and the zoom does
// nothing. It is excluded from `structure_key` instead, so
// zooming re-uploads uniforms rather than recompiling.
|| self.is_zoomed()
}
/// Whether this stage changes the *image*, as opposed to merely what is
/// on screen.
///
/// [`Self::is_active`] answers "must the prologue be emitted", which zoom
/// also requires. This answers "would the exported file differ", which
/// zoom must never affect — it is what tells the interface whether there
/// are edits worth saving.
///
/// The baseline is excluded on purpose. Opening a portrait frame that the
/// camera stored sideways must not light the modified dot, enable the
/// reset, or persuade the sidecar there is something to save — nothing was
/// edited, the file was merely read correctly.
pub fn edits_image(&self) -> bool {
self.angle != 0.0
|| self.quarter_turns != 0
|| self.flip_h
|| self.flip_v
|| !self.crop.is_full()
}
/// Whether the axes are swapped — a 90° or 270° turn, baseline included.
fn swaps_axes(&self) -> bool {
self.effective().0 % 2 == 1
}
/// Whether the map puts output pixels between source pixels.
///
/// False for quarter turns and flips, which are permutations with an
/// exact answer. True once a free angle is involved. The composer reads
/// this to decide between an integer load and a filtered sample — and a
/// warp being active forces interpolation regardless, which is the
/// composer's call to make rather than this stage's.
pub fn needs_interpolation(&self) -> bool {
self.angle != 0.0
}
pub fn set_param(&mut self, id: ParamId, value: f32) {
match id {
ANGLE => self.angle = finite(value, 0.0),
// Descriptor-clamped to 0..3, so the cast cannot wrap.
ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8,
FLIP_H => self.flip_h = value != 0.0,
FLIP_V => self.flip_v = value != 0.0,
CROP_X => {
self.crop = CropRect {
x: value,
..self.crop
}
.normalised()
}
CROP_Y => {
self.crop = CropRect {
y: value,
..self.crop
}
.normalised()
}
CROP_W => {
self.crop = CropRect {
width: value,
..self.crop
}
.normalised()
}
CROP_H => {
self.crop = CropRect {
height: value,
..self.crop
}
.normalised()
}
_ => log::warn!("framing: unknown parameter {id}"),
}
}
pub fn param(&self, id: ParamId) -> f32 {
match id {
ANGLE => self.angle,
ROTATION => f32::from(self.quarter_turns),
FLIP_H => f32::from(u8::from(self.flip_h)),
FLIP_V => f32::from(u8::from(self.flip_v)),
CROP_X => self.crop.x,
CROP_Y => self.crop.y,
CROP_W => self.crop.width,
CROP_H => self.crop.height,
_ => 0.0,
}
}
/// Clear every framing edit.
///
/// The baseline survives, because it was never an edit. "Reset" means
/// *the file as it is*, and the file is upright — so this returns the
/// photograph to how the camera meant it to be seen rather than to how
/// the sensor happened to be scanned.
pub fn reset(&mut self) {
*self = Self {
baseline: self.baseline,
..Self::default()
};
}
/// The output size this framing produces from a source of `(w, h)`.
///
/// The rendered aspect ratio follows from here, which is why this is the
/// one piece of framing both the UI and the GPU pass need before any
/// pixel is shaded: the output texture is allocated from it.
///
/// A free angle does **not** change the output size. The rotated image is
/// sampled into the crop rect as it stands, so straightening a horizon
/// leaves the frame where the user put it and may pull in undefined area
/// at the corners — see [`Self::max_inscribed_crop`] for the rect that
/// avoids that.
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
let (w, h) = if self.swaps_axes() {
(height, width)
} else {
(width, height)
};
// Round rather than truncate: half of a 101px axis should be 51, and
// truncation biases every crop smaller.
let cw = ((w as f32 * self.crop.width).round() as u32).max(1);
let ch = ((h as f32 * self.crop.height).round() as u32).max(1);
(cw, ch)
}
/// The output size ignoring the crop — the whole frame, turned.
///
/// What the crop overlay measures against: it draws the rect the user is
/// selecting, so it needs the shape being selected *from*, not the shape
/// the crop currently produces.
pub fn output_size_uncropped(&self, width: u32, height: u32) -> (u32, u32) {
if self.swaps_axes() {
(height.max(1), width.max(1))
} else {
(width.max(1), height.max(1))
}
}
/// The largest centred crop, at the current angle, containing no
/// undefined area.
///
/// Rotating a rectangle inside its own bounds exposes the corners: there
/// is no source pixel there, and the shader renders it black. This is the
/// rect that avoids it — what a "straighten and auto-crop" gesture would
/// apply, and what the crop overlay should offer as its bound.
///
/// The standard largest-inscribed-rectangle result for a rotated
/// rectangle of the same aspect ratio.
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
if self.angle == 0.0 || width == 0 || height == 0 {
return CropRect::default();
}
let (w, h) = if self.swaps_axes() {
(height as f32, width as f32)
} else {
(width as f32, height as f32)
};
let a = (self.angle * PI / 180.0).abs();
let (sin, cos) = (a.sin(), a.cos());
// Longer and shorter side, so the two cases below stay symmetric.
let (long, short) = if w >= h { (w, h) } else { (h, w) };
let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 {
// Half-constrained: the shorter side alone limits the rectangle.
let half = 0.5 * short;
if w >= h {
(half / sin, half / cos)
} else {
(half / cos, half / sin)
}
} else {
// Fully constrained by both sides.
let cos2 = cos * cos - sin * sin;
((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2)
};
// Back to fractions of the (possibly axis-swapped) frame, centred.
let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0);
let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0);
CropRect {
x: (1.0 - fw) * 0.5,
y: (1.0 - fh) * 0.5,
width: fw,
height: fh,
}
.normalised()
}
/// Uniform values the generated prologue reads.
///
/// A fixed-size block in a fixed slot, like the camera matrix: the
/// prologue is emitted whether or not any operation is active, so its
/// uniforms cannot be positioned by the op loop.
///
/// The angle reaches the shader as sin/cos rather than degrees — a trig
/// call per pixel would recover a value constant across the dispatch.
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
let rad = self.angle * PI / 180.0;
// The view nests *inside* the crop: the prologue applies one rect,
// and two nested rects in the same normalised space compose into one.
// Doing it here rather than in the shader keeps the per-pixel work
// identical whether or not the user is zoomed in, and costs no extra
// uniform slot.
let rect = self.visible_rect();
[
rect.x,
rect.y,
rect.width,
rect.height,
rad.sin(),
rad.cos(),
0.0,
0.0,
]
}
/// The crop and the view composed into the single rect the shader samples.
///
/// Separate from [`Self::uniforms`] so the composition can be tested as
/// the piece of geometry it is, rather than through a uniform array.
pub fn visible_rect(&self) -> CropRect {
CropRect {
x: self.crop.x + self.view.x * self.crop.width,
y: self.crop.y + self.view.y * self.crop.height,
width: self.crop.width * self.view.width,
height: self.crop.height * self.view.height,
}
}
/// The WGSL mapping an output pixel to a **normalised centred** source
/// position, ready for the warp chain.
///
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
/// exactly the space [`crate::lens`] documents, so lens correction
/// composes on top of this without either stage naming the other.
///
/// `aspect` is left in scope alongside it, since the warp chain and the
/// sampler both need it to return to texture coordinates.
pub fn wgsl_prologue(&self) -> String {
// Neutral framing still has to produce `p`, since the warp chain and
// the sampler read it either way. It emits no `---- ` marker: those
// count active stages, and a neutral graph must generate none.
if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated.
let src_dims = textureDimensions(source);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect;
"
.into();
}
let mut s = String::new();
s.push_str(
" // ---- framing ----
// Output pixel -> source position, in the normalised centred space the
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
// corner. Working here rather than in pixels is what makes the map
// independent of the resolution being rendered at.
let src_dims = textureDimensions(source);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
",
);
// The frame `p` is measured in is the one the *user* is looking at,
// and a quarter turn — the file's or the user's — has already swapped
// its axes. Measuring a portrait frame with the landscape aspect
// stretches one axis against the other by `(w/h)²`, which the
// permutation below silently undoes but the straightening above does
// not: a rotation is only a rotation in a space whose axes carry the
// same scale, so in the stretched one it comes out as a shear.
let frame_aspect = if self.swaps_axes() {
" let frame_aspect = vec2<f32>(f32(src_dims.y) / f32(src_dims.x), 1.0);\n"
} else {
" let frame_aspect = aspect;\n"
};
let _ = write!(
s,
"
// Into the crop rect, then into the framed image's own centred space.
{frame_aspect} uv = u.crop_rect.xy + uv * u.crop_rect.zw;
var p = (uv - vec2<f32>(0.5)) * frame_aspect;
"
);
if self.angle != 0.0 {
// Done in the aspect-corrected space, which is the whole reason
// `p` is scaled by `frame_aspect` above: a rotation applied to raw
// 0..1 coordinates on a non-square image shears it rather than
// turning it, and that reads as a rendering fault.
s.push_str(
"
// Straighten, about the frame centre.
p = vec2<f32>(
p.x * u.framing_angle.y - p.y * u.framing_angle.x,
p.x * u.framing_angle.x + p.y * u.framing_angle.y,
);
",
);
}
// The user's turns and mirrors composed with the file's stored
// orientation. One permutation covers both, so honouring the EXIF tag
// adds no per-pixel work over an untagged file.
let (turns, flip_h, flip_v) = self.effective();
if turns != 0 {
// An exact coordinate permutation rather than a rotation through
// the matrix above, which would resample a transform that has an
// exact answer. It is also where the two aspects meet: `p` arrives
// scaled by the framed image's and has to leave scaled by the
// source's, so each axis is divided by the one it is read from and
// multiplied by the one it is written to.
let permutation = match turns {
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / frame_aspect.x);",
2 => " p = -p;",
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / frame_aspect.x);",
};
let _ = write!(
s,
"
// {}° clockwise — an exact permutation, so nothing is resampled.
{permutation}
",
u32::from(turns) * 90
);
}
if flip_h {
s.push_str(" p.x = -p.x;\n");
}
if flip_v {
s.push_str(" p.y = -p.y;\n");
}
s
}
/// Identifies this framing's *structure* — which branches the prologue
/// generates, not the values it reads.
///
/// Deliberately coarse, for the reason the operation hash is: dragging
/// the crop handles or the straighten slider must reuse the compiled
/// pipeline and upload uniforms only. Only the presence of each
/// transform, never its magnitude, may enter this.
///
/// The last bit is *whether the prologue is emitted at all*, which zoom
/// reaches through [`Self::is_active`]. It has to be here even though zoom
/// is not an edit: the neutral prologue never reads `u.crop_rect`, so a
/// pipeline compiled while unzoomed ignores every later view upload. Two
/// framings that generate different WGSL must not share a cache key — the
/// symptom otherwise is scroll-to-zoom on an otherwise-unedited image
/// doing nothing at all, because the first frame compiled the neutral
/// prologue and the hash never moved off it.
///
/// What this must *not* do is vary with the zoom level: the bit is set by
/// any zoom and cleared by none, so a wheel notch is still a uniform
/// upload rather than a shader build.
pub fn structure_key(&self) -> u64 {
// Effective throughout, because this identifies the *generated WGSL*
// and that is what the prologue emits. Two images differing only in
// their stored orientation must not share a compiled pipeline.
let (turns, flip_h, flip_v) = self.effective();
u64::from(!self.crop.is_full())
| u64::from(self.angle != 0.0) << 1
| u64::from(flip_h) << 2
| u64::from(flip_v) << 3
| u64::from(turns) << 4
| u64::from(self.is_active()) << 6
}
}
/// Floats the framing block occupies in the generated uniform struct.
///
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Orientation;
/// The group law, checked against pixels rather than against itself.
///
/// [`Framing::effective`] claims a baseline and a user rotation collapse
/// into one turn plus two mirrors. The claim is only worth anything if the
/// collapsed transform moves every pixel where the two separate ones
/// would, so that is what this asserts, over all 8 × 16 pairs.
///
/// The case that fails without the conjugation swap is any odd baseline
/// turn combined with a user flip — a phone portrait that the user then
/// mirrors. Naive flag-ORing renders it mirrored about the wrong axis,
/// which still looks like a photograph.
#[test]
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
// Non-square and coprime, so no accidental symmetry hides an error.
const SW: u32 = 5;
const SH: u32 = 3;
for tag in 1..=8u16 {
let baseline = Orientation::from_exif(tag);
let (ow, oh) = baseline.oriented_size(SW, SH);
for user_turns in 0..4u8 {
for user_flip_h in [false, true] {
for user_flip_v in [false, true] {
let user = Orientation {
quarter_turns: user_turns,
flip_h: user_flip_h,
flip_v: user_flip_v,
};
let mut f = Framing::new();
f.set_baseline(baseline);
f.rotate_quarters(i32::from(user_turns));
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
let (t, fh, fv) = f.effective();
let combined = Orientation {
quarter_turns: t,
flip_h: fh,
flip_v: fv,
};
// The output size the composed transform produces must
// be the one the two stages produce in sequence.
let (dw, dh) = user.oriented_size(ow, oh);
assert_eq!(
f.output_size(SW, SH),
(dw, dh),
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v}"
);
for y in 0..dh {
for x in 0..dw {
// Display -> oriented -> stored, the long way.
let (ox, oy) = user.source_pixel(x, y, dw, dh);
let stepwise = baseline.source_pixel(ox, oy, ow, oh);
// Display -> stored, in one permutation.
let fused = combined.source_pixel(x, y, dw, dh);
assert_eq!(
stepwise, fused,
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v} \
at ({x},{y})"
);
}
}
}
}
}
}
}
#[test]
fn a_sideways_file_opens_upright_without_counting_as_an_edit() {
// The whole point of the baseline. A phone portrait is 4000x6000 on
// screen and 6000x4000 on disk, and none of that is the user's doing:
// no modified dot, nothing for the sidecar to save.
let mut f = Framing::new();
f.set_baseline(Orientation::from_exif(6));
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
assert_eq!(f.output_size_uncropped(6000, 4000), (4000, 6000));
assert!(!f.edits_image(), "reading the file is not editing it");
// The prologue must still be emitted, or the turn never happens.
assert!(f.is_active());
// And the panel reads back neutral, because the user turned nothing.
assert_eq!(f.param(ROTATION), 0.0);
assert_eq!(f.param(FLIP_H), 0.0);
}
#[test]
fn reset_returns_to_the_file_as_it_is_not_to_the_sensor_as_it_scanned() {
// Reset means "undo my edits". Dropping the baseline here would lay
// every portrait frame back on its side, which reads as a bug in
// reset rather than as the deliberate act it would be.
let mut f = Framing::new();
f.set_baseline(Orientation::from_exif(8));
f.rotate_quarters(1);
f.set_param(ANGLE, -1.5);
f.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
f.reset();
assert_eq!(f.baseline(), Orientation::from_exif(8));
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
assert!(!f.edits_image());
assert_eq!(f.param(ROTATION), 0.0);
assert_eq!(f.angle(), 0.0);
assert!(f.crop().is_full());
}
#[test]
fn a_user_turn_lands_where_it_would_on_an_untagged_file() {
// A quarter turn is a quarter turn: whatever the file's baseline, one
// press of the button must move the image by 90°, and four must
// return it. Otherwise the control means different things on portrait
// and landscape files.
for tag in 1..=8u16 {
let mut f = Framing::new();
f.set_baseline(Orientation::from_exif(tag));
let upright = f.output_size(6000, 4000);
f.rotate_quarters(1);
let (w, h) = f.output_size(6000, 4000);
assert_eq!((w, h), (upright.1, upright.0), "tag {tag}");
f.rotate_quarters(3);
assert_eq!(f.output_size(6000, 4000), upright, "tag {tag}");
assert!(!f.edits_image(), "tag {tag}: four turns is back to neutral");
}
}
#[test]
fn stored_orientation_reaches_the_shader_and_the_pipeline_cache() {
// A neutral graph on a sideways file must not compile the neutral
// prologue — that is the path where the tag is read, recorded, and
// then silently ignored because nothing asked for a turn.
let plain = Framing::new();
let mut sideways = Framing::new();
sideways.set_baseline(Orientation::from_exif(6));
assert_ne!(plain.structure_key(), sideways.structure_key());
assert!(sideways.wgsl_prologue().contains("90° clockwise"));
// Still exact: an orientation is a permutation, never a resample.
assert!(!sideways.needs_interpolation());
}
#[test]
fn a_fresh_framing_is_neutral() {
// The invariant behind "opening an image shows the image".
let f = Framing::new();
assert!(!f.is_active());
assert!(!f.needs_interpolation());
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
assert!(!f.is_zoomed());
}
#[test]
fn zooming_does_not_change_the_exported_image() {
// The property that makes zoom a viewing tool rather than an edit: it
// must not reach the output size or the crop. If it did, exporting
// while zoomed would write the zoomed view.
//
// The structure key is deliberately not asserted here — see
// `zooming_from_neutral_changes_the_structure_key` for why it must
// move, and `zoom_level_does_not_change_the_structure_key` for the
// part that must not.
let mut f = Framing::new();
let before_size = f.output_size(6000, 4000);
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
assert_eq!(
f.output_size(6000, 4000),
before_size,
"zoom resized output"
);
assert!(f.crop().is_full(), "zoom altered the crop");
}
#[test]
fn zooming_from_neutral_changes_the_structure_key() {
// The regression this guards: a neutral framing emits a prologue that
// never reads `u.crop_rect`, so if zooming leaves the key alone the
// GPU reuses that pipeline and the uploaded view is ignored — zoom
// silently does nothing on an otherwise-unedited image.
let mut f = Framing::new();
let neutral = f.structure_key();
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
assert_ne!(
f.structure_key(),
neutral,
"a zoomed framing generates different WGSL and must not share the \
neutral cache key"
);
}
#[test]
fn zoom_level_does_not_change_the_structure_key() {
// The other half of the contract: crossing from unzoomed to zoomed is
// a recompile, but every notch after that is a uniform upload. If the
// magnitude reached the key, every wheel step would stall on a build.
let mut f = Framing::new();
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let zoomed = f.structure_key();
for extent in [0.4, 0.3, 0.2, 0.1] {
f.set_view(CropRect {
x: 0.1,
y: 0.1,
width: extent,
height: extent,
});
assert_eq!(
f.structure_key(),
zoomed,
"zoom level {extent} forced a recompile"
);
}
}
#[test]
fn the_view_nests_inside_the_crop() {
// Both rects live in the same normalised space and the shader applies
// only one, so they must compose. Getting this wrong would make
// zooming inside a crop jump to a different part of the photograph.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 0.5,
});
// The centre quarter *of the crop*.
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let r = f.visible_rect();
// Origin: a quarter into a crop that starts at 0.5 and spans 0.5.
assert!((r.x - 0.625).abs() < 1e-6, "x was {}", r.x);
assert!((r.y - 0.125).abs() < 1e-6, "y was {}", r.y);
// Extent: half of half.
assert!((r.width - 0.25).abs() < 1e-6, "width was {}", r.width);
assert!((r.height - 0.25).abs() < 1e-6, "height was {}", r.height);
}
#[test]
fn a_full_view_leaves_the_crop_exactly_as_it_was() {
// Composition must be an identity when unzoomed, or merely opening an
// image would shift the crop by a rounding error.
let mut f = Framing::new();
let crop = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
f.set_crop(crop);
let r = f.visible_rect();
assert!((r.x - crop.x).abs() < 1e-6);
assert!((r.y - crop.y).abs() < 1e-6);
assert!((r.width - crop.width).abs() < 1e-6);
assert!((r.height - crop.height).abs() < 1e-6);
}
#[test]
fn a_zoomed_framing_emits_the_coordinate_map() {
// Zoom is excluded from the structure hash but must still make the
// prologue active — otherwise the shader samples the whole frame and
// the zoom silently does nothing.
let mut f = Framing::new();
f.set_view(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(f.is_active(), "a zoomed view must emit the crop mapping");
}
#[test]
fn neutral_framing_still_produces_a_position_for_the_warp_chain() {
// The prologue always defines `p` and `aspect`, active or not — the
// warp chain and the sampler read them either way, so a neutral
// framing that skipped them would fail to compile rather than
// rendering an unframed image.
let src = Framing::new().wgsl_prologue();
assert!(src.contains("var p ="), "{src}");
assert!(src.contains("let aspect ="), "{src}");
// ...but none of the transform steps.
assert!(!src.contains("crop_rect"));
assert!(!src.contains("framing_angle"));
}
#[test]
fn neutral_framing_emits_no_stage_marker() {
// `---- ` markers count *active* stages, and a neutral graph must
// generate none — the assertion behind "opening an image shows the
// image" is written against that count.
assert!(!Framing::new().wgsl_prologue().contains("---- "));
let mut f = Framing::new();
f.set_param(ANGLE, 2.0);
assert!(f.wgsl_prologue().contains("---- framing ----"));
}
#[test]
fn an_active_framing_reads_the_crop_rect() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(f.wgsl_prologue().contains("u.crop_rect"));
}
#[test]
fn cropping_changes_the_output_size() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
assert!(f.is_active());
assert_eq!(f.output_size(1000, 800), (500, 400));
}
#[test]
fn a_quarter_turn_swaps_the_output_axes() {
// What makes a landscape frame come out portrait: the output is
// genuinely taller than it is wide.
let mut f = Framing::new();
f.rotate_quarters(1);
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
f.rotate_quarters(1);
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
}
#[test]
fn crop_applies_within_the_rotated_frame() {
// Half of a rotated frame must be half of the *rotated* dimensions,
// or a crop drawn on screen after a rotation lands somewhere else.
let mut f = Framing::new();
f.rotate_quarters(1);
f.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
assert_eq!(f.output_size(6000, 4000), (2000, 6000));
}
#[test]
fn quarter_turns_wrap_in_both_directions() {
let mut f = Framing::new();
f.rotate_quarters(-1);
assert_eq!(f.quarter_turns(), 3);
f.rotate_quarters(1);
assert_eq!(f.quarter_turns(), 0);
f.rotate_quarters(7);
assert_eq!(f.quarter_turns(), 3);
}
#[test]
fn a_crop_cannot_be_driven_degenerate() {
// A zero-extent crop produces a zero-sized texture, which is a device
// error rather than a visibly silly image.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.5,
y: 0.5,
width: 0.0,
height: 0.0,
});
let (w, h) = f.output_size(1000, 1000);
assert!(w >= 1 && h >= 1);
assert!(f.crop().width >= CropRect::MIN_EXTENT);
}
#[test]
fn a_crop_pushed_past_the_edge_stays_inside() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.8,
y: 0.9,
width: 0.5,
height: 0.5,
});
let c = f.crop();
assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge");
assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge");
}
#[test]
fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() {
// Worse than a wrong image: `NaN as u32` is 0, and a zero-sized
// texture is a device error.
let mut f = Framing::new();
f.set_param(CROP_W, f32::NAN);
f.set_param(CROP_X, f32::INFINITY);
let (w, h) = f.output_size(1000, 1000);
assert!(w >= 1 && h >= 1);
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
}
#[test]
fn an_origin_at_its_limit_does_not_panic() {
// Found by the codegen test that drives every parameter to its
// maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to
// just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted
// range rather than resolving it — a crash reachable by dragging a
// crop handle to the edge.
for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] {
let c = CropRect {
x: origin,
y: origin,
width: 1.0,
height: 1.0,
}
.normalised();
assert!(
c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT,
"origin {origin} produced a degenerate rect: {c:?}"
);
}
}
#[test]
fn every_parameter_at_its_extremes_is_survivable() {
// The whole descriptor driven to both ends, which is what a codegen
// test does and what a corrupt sidecar can do.
for p in DESCRIPTOR.params {
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
let mut f = Framing::new();
f.set_param(p.id, p.clamp(value));
let (w, h) = f.output_size(6000, 4000);
assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id);
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn output_size_rounds_rather_than_truncating() {
// Truncation biases every crop smaller; half of 101 should be 51.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
assert_eq!(f.output_size(101, 101), (51, 51));
}
#[test]
fn quarter_turns_and_flips_need_no_interpolation() {
// Why 90° steps are handled apart from the free angle: they have an
// exact answer and must not be resampled.
let mut f = Framing::new();
f.rotate_quarters(1);
f.set_param(FLIP_H, 1.0);
assert!(f.is_active());
assert!(!f.needs_interpolation());
}
#[test]
fn a_free_angle_needs_interpolation() {
let mut f = Framing::new();
f.set_param(ANGLE, 1.5);
assert!(f.needs_interpolation());
assert!(f.wgsl_prologue().contains("u.framing_angle"));
}
#[test]
fn a_turned_frame_is_measured_by_its_own_aspect() {
// `p` is the space the straightening rotates in, so it has to be the
// space the *user* sees. Once a turn has swapped the axes — the
// button's or the file's — the source's aspect is the wrong ruler:
// measuring a 2:3 frame with a 3:2 aspect stretches one axis against
// the other, and the rotation that follows shears instead of turning.
//
// The pixels are asserted in `dr-gpu`'s
// `straightening_a_turned_frame_keeps_a_circle_circular`; this is the
// same claim at the level the code is written at.
for turns in [1u8, 3] {
let mut f = Framing::new();
f.rotate_quarters(i32::from(turns));
f.set_param(ANGLE, 5.0);
let src = f.wgsl_prologue();
assert!(
src.contains(
"let frame_aspect = vec2<f32>(f32(src_dims.y) / f32(src_dims.x), 1.0);"
),
"{turns} turns must measure the frame turned:\n{src}"
);
assert!(src.contains("* frame_aspect;"), "{src}");
}
// An even turn leaves the axes where they were, so the two rulers are
// the same one and nothing has to be recomputed.
for turns in [0u8, 2] {
let mut f = Framing::new();
f.rotate_quarters(i32::from(turns));
f.set_param(ANGLE, 5.0);
assert!(
f.wgsl_prologue().contains("let frame_aspect = aspect;"),
"{turns} turns should reuse the source aspect"
);
}
// And the baseline reaches it the same way a button press does: a
// file stored sideways is a turned frame whether or not it was edited.
let mut sideways = Framing::new();
sideways.set_baseline(dr_types::Orientation::from_exif(6));
sideways.set_param(ANGLE, 5.0);
assert!(sideways
.wgsl_prologue()
.contains("f32(src_dims.y) / f32(src_dims.x)"));
}
#[test]
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
// `p` is scaled by the source aspect, so a permutation that exchanges
// the axes has to undo and reapply it. Without that a 90° turn on a
// 3:2 frame comes out stretched.
let mut f = Framing::new();
f.rotate_quarters(1);
assert!(f.wgsl_prologue().contains("aspect.x"));
}
#[test]
fn the_structure_key_ignores_magnitudes() {
// What the pipeline cache depends on: dragging the straighten slider
// or the crop handles must not recompile.
let mut a = Framing::new();
a.set_param(ANGLE, 1.0);
let mut b = Framing::new();
b.set_param(ANGLE, 4.0);
assert_eq!(a.structure_key(), b.structure_key());
assert_eq!(a.wgsl_prologue(), b.wgsl_prologue());
assert_ne!(a.uniforms(), b.uniforms());
let mut c = Framing::new();
c.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
let mut d = Framing::new();
d.set_crop(CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.4,
});
assert_eq!(c.structure_key(), d.structure_key());
assert_eq!(c.wgsl_prologue(), d.wgsl_prologue());
}
#[test]
fn different_transforms_take_different_structure_keys() {
// The other half of the cache contract: framing that generates
// different code must not reuse another's pipeline.
let mut seen = std::collections::BTreeSet::new();
seen.insert(Framing::new().structure_key());
let mut cropped = Framing::new();
cropped.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(seen.insert(cropped.structure_key()));
let mut angled = Framing::new();
angled.set_param(ANGLE, 2.0);
assert!(seen.insert(angled.structure_key()));
let mut flipped = Framing::new();
flipped.set_param(FLIP_H, 1.0);
assert!(seen.insert(flipped.structure_key()));
for turns in 1..=3 {
let mut f = Framing::new();
f.rotate_quarters(turns);
assert!(seen.insert(f.structure_key()), "{turns} quarter turns");
}
}
#[test]
fn parameters_round_trip() {
let mut f = Framing::new();
for (id, v) in [
(ANGLE, 2.5),
(ROTATION, 2.0),
(FLIP_H, 1.0),
(FLIP_V, 1.0),
(CROP_X, 0.1),
(CROP_Y, 0.2),
(CROP_W, 0.5),
(CROP_H, 0.4),
] {
f.set_param(id, v);
assert_eq!(f.param(id), v, "{id} did not round-trip");
}
}
#[test]
fn every_default_leaves_the_stage_neutral() {
// The same contract the operations honour, checked against the
// descriptor rather than a literal.
let mut f = Framing::new();
for p in DESCRIPTOR.params {
f.set_param(p.id, p.default);
}
assert!(!f.is_active(), "descriptor defaults must be neutral");
}
#[test]
fn every_default_is_within_its_declared_range() {
for p in DESCRIPTOR.params {
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
}
}
#[test]
fn no_parameter_is_declared_twice() {
let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "framing has a duplicate parameter");
}
#[test]
fn reset_returns_to_neutral() {
let mut f = Framing::new();
f.set_param(ANGLE, 3.0);
f.rotate_quarters(1);
f.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.3,
});
assert!(f.is_active());
f.reset();
assert!(!f.is_active());
assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue());
}
#[test]
fn uniforms_carry_the_angle_as_sin_and_cos() {
// The shader never sees degrees: converting here keeps a trig call
// out of every pixel.
let mut f = Framing::new();
f.set_param(ANGLE, 90.0);
let u = f.uniforms();
assert!(
(u[4] - 1.0).abs() < 1e-6,
"sin(90°) should be 1, got {}",
u[4]
);
assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]);
}
#[test]
fn uniforms_are_always_finite() {
// One NaN in the uniform block blanks every pixel.
let mut f = Framing::new();
f.set_param(ANGLE, f32::NAN);
f.set_param(CROP_W, f32::NAN);
assert!(
f.uniforms().iter().all(|v| v.is_finite()),
"{:?}",
f.uniforms()
);
}
#[test]
fn the_uniform_block_is_vec4_aligned() {
// Emitted as whole `vec4`s; a size not divisible by four would
// misalign every operation uniform that follows it.
assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0);
assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS);
}
#[test]
fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() {
assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full());
}
#[test]
fn the_inscribed_crop_shrinks_as_the_angle_grows() {
// Straightening further must cut in further; anything else leaves
// undefined corners inside the frame.
let mut small = Framing::new();
small.set_param(ANGLE, 2.0);
let mut large = Framing::new();
large.set_param(ANGLE, 10.0);
let a = small.max_inscribed_crop(6000, 4000);
let b = large.max_inscribed_crop(6000, 4000);
assert!(a.width > b.width, "{} should exceed {}", a.width, b.width);
assert!(a.width < 1.0, "a rotated frame cannot keep its full width");
}
#[test]
fn the_inscribed_crop_is_centred_and_inside_the_frame() {
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] {
let mut f = Framing::new();
f.set_param(ANGLE, angle);
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
let c = f.max_inscribed_crop(w, h);
assert!(
c.width > 0.0 && c.height > 0.0,
"{angle}° on {w}x{h}: {c:?} is degenerate"
);
assert!(
c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4,
"{angle}° on {w}x{h}: {c:?} extends past the frame"
);
assert!(
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4,
"{angle}° on {w}x{h}: {c:?} is not centred"
);
}
}
}
#[test]
fn the_inscribed_crop_contains_no_undefined_area() {
// The property the derivation exists for, checked directly: every
// corner of the inscribed rect, mapped through the same transform the
// shader applies, must land inside the source.
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] {
let mut f = Framing::new();
f.set_param(ANGLE, angle);
let (w, h) = (6000.0f32, 4000.0f32);
let c = f.max_inscribed_crop(6000, 4000);
let rad = angle * PI / 180.0;
let (sn, cs) = (rad.sin(), rad.cos());
let aspect = w / h;
for (fx, fy) in [
(c.x, c.y),
(c.x + c.width, c.y),
(c.x, c.y + c.height),
(c.x + c.width, c.y + c.height),
] {
let (px, py) = ((fx - 0.5) * aspect, fy - 0.5);
let (rx, ry) = (px * cs - py * sn, px * sn + py * cs);
let (ux, uy) = (rx / aspect + 0.5, ry + 0.5);
assert!(
(-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy),
"{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source"
);
}
}
}
}