Skip to content

Crop tile mapping - #45

Merged
nnmarcoo merged 4 commits into
mainfrom
crop-tile-mapping
Aug 15, 2026
Merged

Crop tile mapping#45
nnmarcoo merged 4 commits into
mainfrom
crop-tile-mapping

Conversation

@nnmarcoo

Copy link
Copy Markdown
Owner

Three display bugs on tiled and resized documents

All three were invisible to the suite and found by using the app. They share one shape: a value in one coordinate space used where another was meant. The spaces coincide in the common case, so the wrong code is correct until a chain has both a crop and a resize, or until the image is large enough to be tiled.

The spaces:

  • source: the image's own pixels
  • document: what the chain outputs
  • kept: the source region the document was made from, narrowed by a crop, unchanged by a resize

Crop stretched the texture on a tiled image

to_doc derived the kept region as src_w - offset.x, source minus the crop's origin, which leaves the crop's extent in the ratio handed to tile_out_rect. A 10000px crop of a 30000px source mapped source pixel 100 to document pixel 33, squeezing the texture by exactly the crop's fraction. It now takes the kept region explicitly, with chain_kept_extent walking the plan the way chain_doc_offset already does.

Only reachable via build_roi_display_bgs when roi_active, which needs multiple tiles. A single-tile source never hits it, which is why it took a large image to see.

A resized document drew at its old size

place_tile measures quads from doc_region, in source pixels, while the view's scale is chosen against the document (fit is viewport / document). A resize keeps every source pixel, so the two differ by exactly the resize ratio. A 30000px source resized to 25% in a 1600x900 viewport drew 3600px wide where fit had chosen 900px, so resizing down left the image large. ViewGeometry now carries doc_size and inv_tile_vp divides the ratio back out. Without a resize the ratio is 1.

Editing a crop never refit the view

update refit from a list naming only ResizeWidth/ResizeHeight, so crop edits, overlay drags, disabling a resize, and removing one all left the view scaled for the previous document. It now compares document_size() across the edit, which is the condition itself and cannot go stale when a modifier is added. Size-preserving edits are still left alone so a deliberate zoom is not disturbed.

Also here

Deferring in prepare_modifiers consumed the dirty it postponed, losing the reprocess entirely. Found while investigating, and not the cause of either symptom above. The decision is now defer_decision, a pure function, so the carry rule is testable. Separately, doc_offset and doc_kept were set after the all-tiles-culled early return, the same staleness the module header already documents for doc_size.

Tests

a_crop_translates_its_tiles_instead_of_scaling_them computed its expectation with the same wrong formula as the code, so it could only ever agree. It now asserts the translation directly, alongside cases for a crop at the origin, crop-then-resize, and resize-with-no-crop.

every_edit_that_changes_the_document_refits promised a general invariant but listed four Resize params and skipped any case whose document did not change, so two of its four were silent no-ops. It now covers crop params and asserts each case actually resizes.

inscribe_transform had no coverage despite positioning every processed texture on the ROI path. It was correct, and tests were added.

414 tests pass with --features heif,av. Each fix was verified by breaking it and watching the relevant test fail.

Still not covered

prepare_modifiers needs a real device and a TiledSource, and ViewPipeline is only ever constructed by iced's renderer, so the deferral path has no test-reachable seam. That is the display-harness gap that let these through.

Fit was re-run from a hardcoded list naming only ResizeWidth and
ResizeHeight, so every other edit that resizes the document left the
view scaled for the previous one: editing a crop, dragging the crop
overlay, disabling a resize, and removing one. Any new
geometry-changing modifier would have joined them.

Compare document_size across the edit instead. That is the actual
condition, it cannot go stale when a modifier is added, and it leaves
size-preserving edits alone so a deliberate zoom is not disturbed.

Opening and closing the crop tool is the one refit this cannot see:
it switches between the cropped and uncropped extents while the
document keeps its size, so SelectTool keeps refitting explicitly.

every_edit_that_changes_the_document_refits promised the general
invariant but its cases were four Resize params, and it skipped any
case whose document did not change, so a no-op case proved nothing.
It now covers crop params too and asserts each case actually resizes.
The two size-preserving params it held move to a test asserting the
view is left alone.
to_doc derived the source region the document was made from as
"source minus the crop's origin", which leaves the crop's own extent
in the ratio handed to tile_out_rect. A crop at the origin therefore
rescaled by doc/src: a 10000px crop of a 30000px source mapped source
pixel 100 to document pixel 33, squeezing the texture across the quad
by exactly the crop's fraction.

Pass the kept region explicitly instead. chain_kept_extent walks the
plan the way chain_doc_offset already does, narrowing at each crop and
ignoring resizes, since a resize changes the document's size without
changing which source pixels it stands for.

This path only runs for per-tile ROIs, so a single-tile source never
reached it. That is why it took a large tiled image to see, and why
every small-image test stayed green.

a_crop_translates_its_tiles_instead_of_scaling_them computed its own
expectation with the same wrong formula, so it could only ever agree
with the code. It now asserts the translation directly, alongside new
cases for a crop at the origin, a crop then a resize, and a resize
with no crop.
prepare_modifiers takes dirty out of pending_source_dirty at the top,
then may defer to refresh_display_transforms and return. That return
dropped the flag, so a frame that asked for a reprocess while the view
was still interacting lost it: nothing marks it again once the view
settles, and the pipeline keeps whatever it last rendered.

Only chains with a non-pointwise modifier can defer, which is why a
plain image is unaffected and one with a Resize is not.

The decision is now defer_decision, a pure function, so the carry rule
is testable. prepare_modifiers itself still needs a real device and a
TiledSource, so the surrounding path remains untested -- the same gap
that let the crop display bugs through.
place_tile measures each tile's quad from doc_region, which is in
source pixels, while the view's scale is chosen against the document:
fit is viewport/document. A resize keeps every source pixel, so
doc_region stays the whole source while the document shrinks, and the
two spaces differ by exactly the resize ratio.

The quads were therefore that much too large. A 30000px source resized
to 25% fitted in a 1600x900 viewport drew 3600px wide where fit had
chosen 900px, which reads as the image refusing to shrink when you
resize it down.

ViewGeometry now carries doc_size and inv_tile_vp divides the ratio
back out. Without a resize the ratio is 1, which is why a plain image
and a crop-only chain were both unaffected: a crop narrows doc_region
and the document together.
@nnmarcoo
nnmarcoo merged commit d93ae3b into main Aug 15, 2026
1 check passed
@nnmarcoo
nnmarcoo deleted the crop-tile-mapping branch August 15, 2026 03:36
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