A view rect that composes with the crop in the same normalised space: nesting one rect in the other is a multiply, so the shader needs no second rect and no extra uniform slot. Zoom is explicitly not an edit. It is excluded from is_active, from the structure hash, and from the sidecar, so a zoomed view exports exactly as an unzoomed one does. is_neutral now asks the framing whether it *edits* rather than whether it is active — otherwise merely zooming would mark a clean file dirty. 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 1:1 inspection show real detail. Assisted-by: LLM
1168 lines
41 KiB
Rust
1168 lines
41 KiB
Rust
//! Framing — crop, straighten, rotate, flip (FR-DEV-3, ARCH §5.2).
|
|
//!
|
|
//! # Why this is not an `Operation`, and not a `Warp` either
|
|
//!
|
|
//! An [`crate::operation::Operation`] is a function from colour to colour. By
|
|
//! the time one runs, the colour has been sampled and the question framing
|
|
//! asks — *which* source pixel does this output pixel come from — has already
|
|
//! been answered. And a crop changes the output's dimensions and aspect
|
|
//! ratio, which no colour fragment can express.
|
|
//!
|
|
//! [`crate::lens::Warp`] is closer: it also rewrites coordinates before the
|
|
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
|
|
//! properties of the lens, defined about the optical axis, over the whole
|
|
//! frame the lens projected. Framing is a decision about *composition*, made
|
|
//! afterwards. The order matters and is not a preference:
|
|
//!
|
|
//! ```text
|
|
//! output pixel → framing → warp (lens) → sample → colour ops → output
|
|
//! ```
|
|
//!
|
|
//! Reading forward, the lens is corrected on the full frame and the crop
|
|
//! then selects from the corrected result. Correcting distortion on an
|
|
//! already-cropped frame would place the optical centre in the wrong spot and
|
|
//! bend the image about a point the lens never saw.
|
|
//!
|
|
//! So framing runs **first** in the coordinate chain, and hands the warp
|
|
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
|
//! corner. Neither stage needs to know the other exists.
|
|
//!
|
|
//! # Why sampling changes with the angle
|
|
//!
|
|
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
|
//! the map is a permutation and an integer `textureLoad` is both correct and
|
|
//! lossless. At any other angle it is not, and nearest-neighbour sampling
|
|
//! makes a straightened horizon visibly stair-step — the commonest use of
|
|
//! this stage, and where the artefact is most obvious. Free angles therefore
|
|
//! need interpolation, which is what [`Framing::needs_interpolation`] tells
|
|
//! the composer. Paying for it only when the angle demands it keeps the
|
|
//! common case exact rather than merely close.
|
|
|
|
use std::f32::consts::PI;
|
|
use std::fmt::Write as _;
|
|
|
|
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
|
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;
|
|
|
|
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
|
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,
|
|
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,
|
|
crop: CropRect::default(),
|
|
view: CropRect::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Framing {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn descriptor(&self) -> &'static OpDescriptor {
|
|
&DESCRIPTOR
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// 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 {
|
|
self.angle != 0.0
|
|
|| self.quarter_turns != 0
|
|
|| self.flip_h
|
|
|| self.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.
|
|
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.
|
|
fn swaps_axes(&self) -> bool {
|
|
self.quarter_turns % 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,
|
|
}
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
*self = 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);
|
|
",
|
|
);
|
|
|
|
s.push_str(
|
|
"
|
|
// Into the crop rect.
|
|
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
|
var p = (uv - vec2<f32>(0.5)) * aspect;
|
|
",
|
|
);
|
|
|
|
if self.angle != 0.0 {
|
|
// Done in the aspect-corrected space, which is the whole reason
|
|
// `p` is scaled by `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.quarter_turns != 0 {
|
|
// An exact coordinate permutation rather than a rotation through
|
|
// the matrix above, which would resample a transform that has an
|
|
// exact answer. Applied to `p`, so the aspect scaling has to be
|
|
// undone and reapplied across the swap.
|
|
let permutation = match self.quarter_turns {
|
|
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
|
2 => " p = -p;",
|
|
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
|
|
};
|
|
let _ = write!(
|
|
s,
|
|
"
|
|
// {}° clockwise — an exact permutation, so nothing is resampled.
|
|
{permutation}
|
|
",
|
|
u32::from(self.quarter_turns) * 90
|
|
);
|
|
}
|
|
|
|
if self.flip_h {
|
|
s.push_str(" p.x = -p.x;\n");
|
|
}
|
|
if self.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.
|
|
pub fn structure_key(&self) -> u64 {
|
|
u64::from(!self.crop.is_full())
|
|
| u64::from(self.angle != 0.0) << 1
|
|
| u64::from(self.flip_h) << 2
|
|
| u64::from(self.flip_v) << 3
|
|
| u64::from(self.quarter_turns) << 4
|
|
}
|
|
}
|
|
|
|
/// 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::*;
|
|
|
|
#[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, the structure hash, or the crop.
|
|
// If it did, exporting while zoomed would write the zoomed view.
|
|
let mut f = Framing::new();
|
|
let before_size = f.output_size(6000, 4000);
|
|
let before_key = f.structure_key();
|
|
|
|
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_eq!(f.structure_key(), before_key, "zoom forced a recompile");
|
|
assert!(f.crop().is_full(), "zoom altered the crop");
|
|
}
|
|
|
|
#[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_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"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|