Skip to content

fix: Snap computed sizes to whole pixels - #144

Merged
elektronaut merged 5 commits into
mainfrom
vector2d-3.0-upgrade-v2
Sep 19, 2026
Merged

elektronaut merged 5 commits into
mainfrom
vector2d-3.0-upgrade-v2

Conversation

@elektronaut

Copy link
Copy Markdown
Owner

ImageSizing#fit scales in floating point, so an axis that should land exactly on a whole pixel can come out a few ulps below it, and since every caller floors the result, that pixel is lost. A 320x200 image requested at 322x rendered at 319x200 and at 323x at 320x199 instead of its own size in both cases; plain downscaling drifts the same way, with 204 of the 3024 possible width-only requests on a 3024x4032 image coming out short, and the crop path losing a row or column for the same reason. Snapping any axis that scaling leaves within a rounding error of a whole pixel onto it covers all three sites at once.

The snap runs before #142's require_pixels! so that a size rounding up to one pixel isn't rejected for landing a rounding error below it, and #snap passes non-finite values through untouched so that guard still reports them as InvalidSizeOptions.

This dates back to 8da8729 (2014), which both swapped the clamp to run after the fit and changed the final quantization from round to floor — neither half produces the bug alone.

constrain_both and contain are candidates for removal in vector2d
3.0. Both have equivalents in 2.3, so dropping them now keeps the
eventual version bump a gemspec-only change.

constrain_both is a plain alias for fit. contain is inlined as a
private helper, which also removes a readability trap: the old
size.fit(fit_size) and size.contain(fit_size) shared a receiver but
meant structurally opposite things.

No behavior change.
resize was documented as only scaling down, but it fits the image to
max_size in both directions: a 320x200 image resized to "640x640" is
rewritten at 640x400. Also document the string form of max_size, which
was already accepted but undocumented.
ImageSizing#fit scales in floating point, so an axis that should
land exactly on a whole pixel can come out a few ulps below it.
Callers floor the result, turning that into a lost pixel.

A 320x200 image requested at "322x" rendered at 319x200, and at
"323x" it rendered at 320x199, instead of the image's own size in
both cases. Plain downscaling drifts the same way: on a 3024x4032
image, 204 of the 3024 possible width-only requests came out a
pixel short. The crop path loses a row or column for the same
reason.

Snap any axis that scaling leaves within a rounding error of a
whole pixel onto it.
ImageSizing#fit conflicted: both sides added a step after the
contain call. Sizes are now snapped to whole pixels first and the
result validated afterwards, so a size that rounds up to a pixel
isn't rejected for landing a rounding error below one. #snap leaves
non-finite values alone, so require_pixels! keeps reporting those as
InvalidSizeOptions.
It has a single use site.
@elektronaut
elektronaut merged commit 1e16feb into main Sep 19, 2026
7 checks passed
@elektronaut
elektronaut deleted the vector2d-3.0-upgrade-v2 branch September 19, 2026 22:11
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