Straighten a portrait frame in the frame the user is looking at

The framing prologue built the centred position `p` by scaling with the
source's aspect, then straightened, then permuted the quarter turns. On a
landscape frame those are one space and it worked. Once a turn has swapped
the axes — the rotate button, or a file whose EXIF tag says the camera was
held sideways — they are not: `p` was measured with the source's ruler on a
frame that is no longer that shape, stretching one axis against the other by
(w/h)², which is 2.25 on a 3:2 photograph.

The quarter-turn permutation happened to undo that stretch, so rotation alone
looked right, which is how this survived. The straighten in between did not,
and a rotation in a space whose axes carry different scales is a shear.

`p` is now built in `frame_aspect` — the frame as the user sees it — and the
permutation becomes the one place the two rulers meet: each axis divided by
the aspect it is read from, multiplied by the aspect it is written to.

Asserted on pixels rather than on the generated WGSL, because reading the
shader and reasoning about which space `p` lives in is how the wrong formula
got written in the first place: a disc, straightened by 20° on a turned 3:2
frame, must come back circular by every route to a swapped frame — the
button, the tag, and the two composed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:17:54 +02:00
co-authored by Claude Opus 5
parent f1cd6ed5b3
commit f76e024f41
2 changed files with 244 additions and 51 deletions
+74 -11
View File
@@ -674,18 +674,32 @@ impl Framing {
",
);
s.push_str(
// 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.
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
var p = (uv - vec2<f32>(0.5)) * aspect;
",
// 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 `aspect` above: a rotation applied to raw 0..1
// coordinates on a non-square image shears it rather than
// `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(
"
@@ -706,12 +720,14 @@ impl Framing {
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. Applied to `p`, so the aspect scaling has to be
// undone and reapplied across the swap.
// 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 / aspect.x);",
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 / aspect.x);",
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / frame_aspect.x);",
};
let _ = write!(
s,
@@ -1283,6 +1299,53 @@ mod tests {
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