Skip to content

Fix quarter-turn crop truncation by separating output rotation - #330

Merged
muukii merged 9 commits into
mainfrom
fix/quarter-turn-crop
Sep 27, 2026
Merged

muukii merged 9 commits into
mainfrom
fix/quarter-turn-crop

Conversation

@muukii

@muukii muukii commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Quarter-turning a crop could cut a rectangular image down to a square, lose a pixel on odd dimensions, or change its selection when reopened and confirmed. The crop now keeps its selection before output rotation and renders in this order: straighten → crop → quarter turn. A full 301×200 image exports as 200×301 after a sideways turn, preserving every source pixel.

Changes

  • Rotation updates the output orientation without rewriting the selected rectangle. Aspect locks, guide layout, Tool coordinates, brush sizing, and face-detection crops use the same contract.
  • Share source-to-output geometry between rendering and Tool surfaces. Use exact quarter-turn matrices, with no width/height parity adjustment or extra source row removal.
  • Fit straightened selections before output rotation, preserving valid thin crops. Remove the legacy crop renderer and obsolete geometry helpers.
  • Done commits the current proposed crop directly. Reopening and confirming an untouched crop no longer reconstructs its geometry from UIView layout. Keep the existing full-axis drag correction.

API and persistence changes

  • CropFeature.cropRect, its display-coordinate adapter, and CropEditingState.cropExtent now describe the selection before the output quarter turn. Code that authored rectangles in the previous output orientation must adopt this convention.
  • Crop persistence uses schema 3. Versions 1 and 2 are rejected; no migration or compatibility adapter is included.
  • Direct Parametric crops use the same requested output canvas at every rotation, with transparent pixels outside the input. Fractional raster bounds can round outward in Core Image.

Validation

Validated with Xcode 27 on iOS Simulator:

  • BrightroomEngineTests: 184 Swift Testing tests in 41 suites, plus 4 XCTest tests, passed.
  • BrightroomParametricTests: 105 tests in 11 suites passed.
  • SwiftUIDemo build and git diff --check passed.
  • Independent pixel checks cover full and partial selections, odd/even dimensions, all quarter turns, straighten, nonzero input extents, and consecutive crops. UI tests cover repeated rotation, open/Done preservation, viewport readback, and explicit Tool coordinate mappings.

Remaining behavior

Done uses the latest recorded interaction state. Pressing Done or switching tools before a scroll/pinch settles can omit the final unrecorded movement. This change does not add synchronous gesture flushing. Physical-device verification has not been performed.

muukii and others added 9 commits September 27, 2026 02:53
After rotating a full-image crop by 90° or 270° in PhotosCrop, the crop
frame shows the whole rotated image, but Done committed and exported a
centered H×H square.

CropView keeps the frame's center in image coordinates and takes the
output orientation, so a quarter-turned full-image frame is
(W/2 - H/2, H/2 - W/2, H, W): it extends past the unrotated image bounds
while CropFeature.apply, rotating about its center, fills it exactly.
PixelCropRect(cropExtent:in:) clamped that output-oriented rect to the
unrotated bounds, cutting it down to the square.

RenderCrop now snaps and clamps the crop's footprint on the source (the
rect turned back about its center) and turns the result back. Under a
sideways turn the footprint also keeps an even width-height difference,
so the turned rect stays on the pixel grid instead of half pixels.
Unrotated and half-turn crops are unchanged.

The UI paths that shared the unrotated-bounds assumption use the same
footprint rule: seeding CropEditingState from a stored crop (reopening
the editor), the drag normalization and aspect-ratio bounding in
CropView.record(), and fitting a locked aspect ratio after a rotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored 90° or 270° crop whose center lies outside the image can
overlap the image while its footprint on the source, which shares that
center, misses it entirely. Reopening such a crop made
CropGeometry.fittingRect(rect:in:rotation:respectingAspectRatio:)
intersect the footprint with the image, get a null rect, and trip its
assertion in Debug builds (Release seeded the editor with a null rect).
5.1.0 clamped the rect itself, so it opened.

When the footprint misses the image, the rect is now first clamped to
the image as an unrotated rect is. That brings its center inside, so the
footprint then overlaps the image and is clamped as usual. Unrotated and
half-turn rects take the same path as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the editor on a 90° or 270° crop of an image whose width and
height differ by an odd number of pixels, and tapping Done without
touching it, lost about 2 px per cycle.

On Done, CropView re-reads the frame from its views, a fraction of a
pixel off. The inward snap then drops the pixel line the frame only
nearly covers (the 1 px creep 5.1.0 already had), which leaves the
footprint's width-height difference odd, and trimmedForQuarterTurn()
dropped one more line to make it even again.

A footprint needs an even difference, but dropping a line of fully
covered pixels misses the requested footprint by a whole pixel. The
snapper now picks the even rect closest to the request: when the
request partly covers the pixel line just outside an edge, the rect
takes the most-covered such line; only when there is none does the
longer side lose a pixel, as before. For the sub-pixel error on Done
this takes back the line the inward snap dropped, so the stored crop
stays the same. Integral footprints, such as a full image turned a
quarter, snap as before, and unrotated and half-turn crops do not take
this path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a quarter turn and a straighten angle, the snapper clamps the
crop's 90° footprint, not the area the crop actually samples (the frame
turned back by the whole angle). A thin frame can sample only image
pixels while its 90° footprint is longer than the image, so it is
stored a few pixels shorter than the frame shows. 5.1.0 clamped the
frame itself, which kept such a frame but cut the whole-image quarter
turn down to a square.

The export stays inside the frame and inside the image. Checking the
straightened area needs a footprint that may extend past the image in
the snapper, the reopen clamp and CropView's aspect-ratio bounding, so
it is left for a separate change. The test pins the case with
withKnownIssue, so it reports when the behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4756382 lets the snapper take a partly covered pixel line to give a
sideways footprint an even width-height difference. That line is inside
the image, but with a straighten angle the crop samples the footprint
turned by that angle, which reaches further out. CropView fits a
straightened frame so that this area touches the image edge, so the
extra line could pull it up to about a pixel past the edge, and the
export got semi-transparent corners (for example 6 px with alpha 201
for a 301x200 image turned 90° and straightened by 2°). bfb68ca and
5.1.0 did not do this.

A line is now taken only while the footprint, turned by the straighten
angle about its center, stays inside the image. Otherwise the longer
side loses a pixel, as in bfb68ca. Without straighten the check always
passes, so unstraightened crops snap as in 4756382.

The doc comments of RenderCrop.cropRect and CropFeature(displayCropRect:)
now say that a sideways crop may take one partly covered line instead of
snapping strictly inward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
770dc0f clamped the rect before its footprint only when the footprint
missed the image entirely, although its doc comment talks about rects
whose center lies outside. A malformed turned crop whose footprint only
grazes the image skipped that step and reopened as a sub-pixel sliver
(a footprint overlapping by 0.01 px reopened as 55 x 0.01 and was
committed again as 55 x 1), where 5.1.0 clamped it to a usable frame.

The rect is now clamped first whenever its center is outside the image,
which matches the comment and covers both cases. A well-formed crop
always has its center inside, and unrotated and half-turn rects skip the
step as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4756382 described the 2 px loss per open and Done cycle as an odd
width-height case, but it hit any 90° or 270° crop: once the sub-pixel
error on Done made the inward snap drop one line, the difference was
odd, and the parity trim dropped a second one. With bfb68ca alone, a
300x200 image turned 90° through CropView was already stored 2 rows
short on the first Done. 4756382 fixed that as well, so the test now
runs on 300x200 as well as 301x200 and is named for what it checks.

What stays stable is the whole-image turn (and an aspect-locked one).
A partial or straightened turned crop can still change by about 1 px
per cycle, the same creep 5.1.0 has for unrotated crops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment said the frame is turned back by 120°. `.quarterCW` is −90°
and the straighten is added to it, so the total is −60°. The reach it
quotes (99.9 px) is the same for 60° and 120°, so the case is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@muukii muukii changed the title Keep the whole image when a crop is turned a quarter Preserve crop selections through quarter-turn rotation Sep 27, 2026
@muukii muukii changed the title Preserve crop selections through quarter-turn rotation Separate quarter-turn rotation from straightening and cropping Sep 27, 2026
@muukii muukii changed the title Separate quarter-turn rotation from straightening and cropping Fix quarter-turn crop truncation by separating output rotation Sep 27, 2026
@muukii
muukii merged commit d7383c4 into main Sep 27, 2026
2 checks passed
@muukii
muukii deleted the fix/quarter-turn-crop branch September 27, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant