Skip to content

EGR-15: Rotate теряет качество изображения - #53

Open
freethinkel wants to merge 1 commit into
mainfrom
agent/egr-15-rotate
Open

EGR-15: Rotate теряет качество изображения#53
freethinkel wants to merge 1 commit into
mainfrom
agent/egr-15-rotate

Conversation

@freethinkel

Copy link
Copy Markdown
Owner

Task: https://linear.app/freethinkel/issue/EGR-15/rotate-teryaet-kachestvo-izobrazheniya

Надо сделать также как и с resize сохранять оригинал изображение для сессии


Generated by shepherd: run run_60e6c63655, agent claude, workspace wD.

A turn used to resample the pixels the previous turn produced: dragging the
angle handle fires one per pointermove, so a slow drag smeared the art while a
single 60° turn stayed sharp. The pre-rotation bitmap is now pinned in the
cache (ImageCache.rot0, next to the resize/adjust `original`) together with the
angle it already carries, and every turn renders that pin at the running total
instead — twelve 5° steps land on the same pixels as one 60° turn, and coming
back to the pinned angle restores the art exactly.

The pin is per session and is dropped wherever the art is re-baked or rescaled
(resize, glyph re-render, replace, clear, invert), since it would then be pixels
of the wrong size. An asset's `adjust` is re-applied when drawing from an older
pin, because the turned pixels bake it in the way they always did.

Also round the layer's re-centring half-pixel symmetrically about zero: growing
by 45px moved it 23 but shrinking back moved it 22, so turning an angle back and
forth walked the layer across the dial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P69VpBDQA4fmgwEq8yFNE1
const cf = a.cf === 4 && deg % 90 !== 0 ? 5 : a.cf;
const sw = rot0.src.width,
sh = rot0.src.height;
const w = size(sw * cos + sh * sin),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output size is now derived from the cached bitmap's dimensions (rot0.src.width/height) instead of the asset's a.w/a.h. $cache is not part of the undo history, so the two can legitimately disagree, and then a rotation silently resizes the widget.

Scenario: resize a 100x40 widget to 200x80 (cache.bitmap is now 200x80, rot0 cleared), press Ctrl+Z (the doc goes back to 100x40, the cache is untouched — undo in doc.model only swaps $doc), then turn it 15°. The fresh pin is the stale 200x80 bitmap, so w = size(200·cos15 + 80·sin15) = 214, h = 129 — the widget jumps to more than double its size on the canvas. The old code computed the box from a.w/a.h and drew the stale bitmap scaled into it, so it stayed put. Same path via replace-image + undo.

Recording the asset size at pin time (w: a.w, h: a.h next to deg) and drawing rot0.src into that box keeps the pin lossless and keeps the box tied to the document.

// rounded symmetrically about zero: Math.round(-22.5) is -22 but Math.round(22.5) is 23, and
// that half pixel would walk the layer one step every time an angle is turned and turned back
const half = (v: number) => Math.sign(v) * Math.round(Math.abs(v) / 2);
const dx = grown ? half(grown.w - first.w) : 0;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

half() fixes the asymmetry for one turn and its exact inverse, but the offset is still computed per step against the previous asset size (grown.w - first.w), so the position still accumulates rounding across a stepped turn — which is exactly the case this PR is about. The pixels now match a single turn; x/y do not.

Measured for a 100x40 frame turned to 60°:

step stepped (dx, dy) one 60° turn
15° (the angle field's step) -7, 34 -8, 34
-9, 37 -8, 34
-15, 52 -8, 34

So four clicks of the angle spinner leave the art 1px off where typing 60 puts it, and a 200x20 hand nudged a degree at a time ends up 16px off. editor-rotate.browser.test.ts compares w/h and pixels between the stepped and one-shot runs but not x/y, which is why it passes.

Since rot0 already pins the geometry, the offset can be made exact by measuring both sides against the pin rather than against the previous step: dx = half(grown.w - pinW) - half(first.w - pinW) (with pinW = the asset width at pin time). Then the per-step offsets telescope and any path to the same angle lands on the same pixel.

cache: {
bitmap: fresh.bitmap ?? (await bitmapOf(fresh)),
original: undefined,
rot0: undefined,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch clears original and now rot0, but still not accent — unlike the JPEG branch three lines up. bmpOf in render/canvas.ts returns c?.accent ?? c?.bitmap, so for an accent-flagged widget (meta flags === 4) replacing a frame's PNG keeps drawing the previous image's tinted silhouette, at the previous size, until the accent color changes and accentFx reruns.

Pre-existing, but this hunk rewrites the literal, so it's a one-word fix.

Suggested change
rot0: undefined,
rot0: undefined,
accent: undefined,

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