Skip to content

Hash PixelAspectRatio by the ratio it compares - #331

Merged
muukii merged 2 commits into
mainfrom
fix/pixel-aspect-ratio-hash
Sep 27, 2026
Merged

muukii merged 2 commits into
mainfrom
fix/pixel-aspect-ratio-hash

Conversation

@muukii

@muukii muukii commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

PixelAspectRatio defines == on the proportion (height / width), so 4:3, 8:6 and a 4032x3024 image size are equal. Its Hashable conformance, however, was still synthesized, which hashes the stored width and height. Equal values could therefore produce different hash values, which breaks the Hashable contract:

  • Set([4:3, 8:6]) could keep both elements, depending on bucket placement.
  • A Dictionary keyed by 4:3 could miss a lookup with 4032x3024.

This PR gives PixelAspectRatio an explicit hash(into:) that hashes the same proportion == compares.

Who is affected

No code in the library hashes PixelAspectRatio: the types that store one (CropView.State, SwiftUICropView.StateSnapshot, CropEditingState, PhotosCropAspectRatioSelection, SwiftUIPhotosCropView.AspectRatioOptions) are only Equatable or have no conformance, and the one ForEach uses id. The fix matters for host apps that put PixelAspectRatio in a Set, use it as a Dictionary key, or use ForEach(id: \.self). There, equal ratios such as 16:9 and 1920x1080 now collapse into one element, as == already said.

Geometry.swift is identical on main, v5 and the v6 branch, so the same bug is still there until this is merged forward. The v6 branch has not touched the file, so merging main into it after this lands should be clean.

Equality semantics are unchanged

I kept ratio equality because every caller in the library relies on it:

  • CropView.setCroppingAspectRatio(_:) and CropEditingState.updateCropExtentIfNeeded(toFitAspectRatio:) skip re-fitting the crop when the new ratio equals the current one. A 4032x3024 original and a 4:3 preset describe the same crop shape, so they should not re-fit.
  • PhotosCropAspectRatioSelection.sync(aspectRatio:originalAspectRatio:) recognizes that a ratio coming back from the crop view is the image's original ratio (or its swapped form) by comparing with ==.
  • The PhotosCrop picker compares selection.isRatio(_:) against minimized presets, and checks != .square.

Structural equality would make these checks fail for equivalent ratios that are written with different numbers, so the fix is on the hashing side.

id stays structural

id is left as the exact width/height pair (it was already "\(width.bitPattern), \(height.bitPattern)"). Its only use is ForEach(Self.horizontalRectangleAspectRatios) in the PhotosCrop picker. Identifiable identity does not have to match ==, and a structural id keeps presets that share a proportion (for example 5:4 and 10:8) as separate, stable rows. Keying rows by ratio would make such presets collide in ForEach. The id is now documented as finer than ==.

Notes

  • Equality is exact, with no tolerance, on purpose. A tolerance is not transitive (a ≈ b and b ≈ c do not give a ≈ c), so no hash could agree with it. Equal proportions written with exact numbers (integers, or values like 1.5:1 vs 3:2) divide to the same correctly rounded CGFloat, so they hash equally. Inexact decimal inputs can differ: 0.3:0.1 is not equal to 3:1. This is unchanged from before.
  • The proportion is height / width, which gives these edge cases (all covered by a test):
    • A zero height gives +0 or -0 (for 4:0 and -4:0). They are equal, and Double hashing already treats them as the same value.
    • A zero width gives an infinite proportion, so 0:3 and 0:7 are equal and hash equally. -0:3 is negative infinity and is not equal to 0:3.
    • A zero width and height divide to NaN, which is not equal to itself, as before. This PR does not change that.
  • localizedText still prints the authored numbers, so two equal values can render different text (4:3 vs 8:6). Callers that need a canonical label already use _minimized().

Tests

New Dev/Tests/BrightroomEngineTests/PixelAspectRatioTests.swift. With main's Geometry.swift, 4 of its 6 tests fail (hash values, Set size, Dictionary lookup, zero sides); the other two check behavior this PR keeps.

  • Equal proportions compare equal, and swapped ratios do not.
  • 4:3 scaled by 1...100 has the same hash value as 4:3.
  • A Set of 100 scaled 16:9 values holds one element, or two when the swapped values are added.
  • A Dictionary keyed by 4:3 / 16:9 finds entries through 4032x3024 / 1920x1080, and through 4:3 and 16:9 scaled by 1...100. Swift seeds hashing per process, so with the old hash a single lookup can still hit the equal key by chance; the 200 scaled lookups make the test fail reliably on main (200 of 200 runs of a standalone copy of the old code failed it).
  • Zero sides: 4:0 and -4:0 are equal with equal hash values, 0:3 and 0:7 likewise, -0:3 differs from 0:3, and 0:0 is not equal to itself.
  • id differs for 5:4 and 10:8, and is stable for the same pair.

Verification:

  • BrightroomEngineTests (full, 174 tests): passed
  • PixelAspectRatioTests with main's Geometry.swift: 4 of 6 failed, as expected
  • Brightroom-Package BrightroomParametricTests: passed
  • SwiftUIDemo build: succeeded

🤖 Generated with Claude Code

muukii and others added 2 commits September 27, 2026 12:51
PixelAspectRatio compares by height / width, so 4:3, 8:6 and a 4032x3024
image size are equal, but its synthesized hash(into:) combined the stored
width and height. Equal values could hash differently, so a Set could
keep duplicate ratios and a Dictionary keyed by 4:3 could miss 4032x3024.

Hash the same proportion that == compares. Ratio equality stays: crop
re-fitting and the PhotosCrop picker rely on 4032x3024 matching 4:3.
`id` stays the exact width/height pair so ForEach rows over presets that
share a proportion remain distinct; both contracts are now documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single lookup can hit an equal key by chance under the per-process hash
seed, so the dictionary test now also looks up 4:3 and 16:9 scaled by
1...100. A new test pins the exact-proportion edge cases: +0 and -0,
infinite proportions from a zero width, and NaN from zero by zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@muukii
muukii merged commit 664bd33 into main Sep 27, 2026
2 checks passed
@muukii
muukii deleted the fix/pixel-aspect-ratio-hash branch September 27, 2026 05:45
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