fix(crop): fix editor positioning for flipped images with asymmetric crops
The crop editor (getImageEditorGeometry) was anchoring on a view-space point that maps differently in cropped vs uncropped local space when flipped. For asymmetric crops + flip, the full image would appear shifted from where the visible image was — the user saw the image "jump" when entering crop mode. Fix: anchor on the container's local origin instead. In the cropped sprite, local (0,0) corresponds to source pixel (srcRect.x, srcRect.y). Position the editor so that same source pixel maps to the same world point in both cropped and uncropped states. This works correctly for all flip/rotation/crop combinations.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
normalizeImageTransformData,
|
normalizeImageTransformData,
|
||||||
getImageTransformedCorners,
|
getImageTransformedCorners,
|
||||||
getImageWorldBounds,
|
getImageWorldBounds,
|
||||||
|
getBoundsFromPoints,
|
||||||
transformPoint,
|
transformPoint,
|
||||||
} from './imageTransforms';
|
} from './imageTransforms';
|
||||||
|
|
||||||
@@ -177,20 +178,17 @@ describe('getImageEditorGeometry', () => {
|
|||||||
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
|
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('crop entry after flipX+flipY preserves anchor', () => {
|
it('crop entry after flipX+flipY preserves visible bounds', () => {
|
||||||
const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } });
|
const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } });
|
||||||
// Anchor is the display crop's top-left in world space (not AABB min)
|
const visibleBounds = getImageWorldBounds(data);
|
||||||
const displayCrop = getImageDisplayCropRect(data);
|
|
||||||
const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
|
|
||||||
|
|
||||||
const eg = getImageEditorGeometry(data);
|
const eg = getImageEditorGeometry(data);
|
||||||
expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01);
|
// The crop corners in editor should match the original visible bounds
|
||||||
expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01);
|
const cropBounds = getBoundsFromPoints(eg.cropWorldCorners);
|
||||||
|
expectClose(cropBounds.x, visibleBounds.x, 0.5);
|
||||||
// The editor's crop region should match the original crop region in world space
|
expectClose(cropBounds.y, visibleBounds.y, 0.5);
|
||||||
const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
|
expectClose(cropBounds.w, visibleBounds.w, 0.5);
|
||||||
expectClose(editorCropCorner.x, expectedAnchor.x, 1);
|
expectClose(cropBounds.h, visibleBounds.h, 0.5);
|
||||||
expectClose(editorCropCorner.y, expectedAnchor.y, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('crop entry after rotate preserves anchor', () => {
|
it('crop entry after rotate preserves anchor', () => {
|
||||||
@@ -473,6 +471,13 @@ describe('ungroup image position preservation (integration)', () => {
|
|||||||
expectClose(ungroupedDisplay.y, worldDisplay.y, 0.01);
|
expectClose(ungroupedDisplay.y, worldDisplay.y, 0.01);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cropped + flipped image in non-rotated group round-trips', () => {
|
||||||
|
const img = makeImage({ x: 150, y: 80, flipX: true, crop: { x: 0.2, y: 0, w: 0.6, h: 1 } });
|
||||||
|
const result = simulateGroupAndUngroup(img, { x: 100, y: 50, sx: 1, sy: 1, angle: 0 });
|
||||||
|
expectClose(result.x, img.x, 0.01);
|
||||||
|
expectClose(result.y, img.y, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
it('non-flipped image in scaled group round-trips', () => {
|
it('non-flipped image in scaled group round-trips', () => {
|
||||||
const img = makeImage({ x: 150, y: 80 });
|
const img = makeImage({ x: 150, y: 80 });
|
||||||
const result = simulateGroupAndUngroup(img, { x: 100, y: 50, sx: 2, sy: 2, angle: 0 });
|
const result = simulateGroupAndUngroup(img, { x: 100, y: 50, sx: 2, sy: 2, angle: 0 });
|
||||||
@@ -485,3 +490,114 @@ describe('ungroup image position preservation (integration)', () => {
|
|||||||
expectClose(resultBounds.w, origBounds.w * 2);
|
expectClose(resultBounds.w, origBounds.w * 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Integration: Crop → Flip → Re-enter Crop ───────────────
|
||||||
|
// Simulates: user crops an image, flips it, then enters crop mode again.
|
||||||
|
// The editor geometry (full uncropped image) must be positioned so the
|
||||||
|
// crop rectangle aligns with the current visible bounds.
|
||||||
|
|
||||||
|
describe('crop → flip → re-enter crop mode (integration)', () => {
|
||||||
|
it('editor geometry aligns crop region with visible bounds after flipX', () => {
|
||||||
|
// Step 1: Cropped image
|
||||||
|
const data = makeImage({ x: 100, y: 100, w: 400, h: 300, crop: { x: 0.2, y: 0, w: 0.6, h: 1 } });
|
||||||
|
const visibleBefore = getImageWorldBounds(data);
|
||||||
|
|
||||||
|
// Step 2: Flip (what operations.flipHorizontal does)
|
||||||
|
data.flipX = true;
|
||||||
|
const visibleAfterFlip = getImageWorldBounds(data);
|
||||||
|
|
||||||
|
// Visible bounds should not change after flip
|
||||||
|
expectClose(visibleAfterFlip.x, visibleBefore.x, 0.01);
|
||||||
|
expectClose(visibleAfterFlip.y, visibleBefore.y, 0.01);
|
||||||
|
expectClose(visibleAfterFlip.w, visibleBefore.w, 0.01);
|
||||||
|
expectClose(visibleAfterFlip.h, visibleBefore.h, 0.01);
|
||||||
|
|
||||||
|
// Step 3: Enter crop mode — get editor geometry
|
||||||
|
const eg = getImageEditorGeometry(data);
|
||||||
|
|
||||||
|
// The crop rectangle in editor space should match the current visible bounds
|
||||||
|
const cropCorners = eg.cropWorldCorners;
|
||||||
|
const cropBounds = {
|
||||||
|
x: Math.min(...cropCorners.map(c => c.x)),
|
||||||
|
y: Math.min(...cropCorners.map(c => c.y)),
|
||||||
|
w: Math.max(...cropCorners.map(c => c.x)) - Math.min(...cropCorners.map(c => c.x)),
|
||||||
|
h: Math.max(...cropCorners.map(c => c.y)) - Math.min(...cropCorners.map(c => c.y)),
|
||||||
|
};
|
||||||
|
expectClose(cropBounds.x, visibleAfterFlip.x, 0.01);
|
||||||
|
expectClose(cropBounds.y, visibleAfterFlip.y, 0.01);
|
||||||
|
expectClose(cropBounds.w, visibleAfterFlip.w, 0.01);
|
||||||
|
expectClose(cropBounds.h, visibleAfterFlip.h, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editor geometry aligns crop region after flipX + flipY', () => {
|
||||||
|
const data = makeImage({ x: 100, y: 100, w: 400, h: 300, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } });
|
||||||
|
const visibleBefore = getImageWorldBounds(data);
|
||||||
|
|
||||||
|
// Flip both axes
|
||||||
|
data.flipX = true;
|
||||||
|
data.flipY = true;
|
||||||
|
const visibleAfterFlip = getImageWorldBounds(data);
|
||||||
|
|
||||||
|
// Bounds unchanged
|
||||||
|
expectClose(visibleAfterFlip.x, visibleBefore.x, 0.01);
|
||||||
|
expectClose(visibleAfterFlip.w, visibleBefore.w, 0.01);
|
||||||
|
|
||||||
|
// Enter crop mode
|
||||||
|
const eg = getImageEditorGeometry(data);
|
||||||
|
const cropCorners = eg.cropWorldCorners;
|
||||||
|
const cropBounds = {
|
||||||
|
x: Math.min(...cropCorners.map(c => c.x)),
|
||||||
|
y: Math.min(...cropCorners.map(c => c.y)),
|
||||||
|
w: Math.max(...cropCorners.map(c => c.x)) - Math.min(...cropCorners.map(c => c.x)),
|
||||||
|
h: Math.max(...cropCorners.map(c => c.y)) - Math.min(...cropCorners.map(c => c.y)),
|
||||||
|
};
|
||||||
|
expectClose(cropBounds.x, visibleAfterFlip.x, 0.01);
|
||||||
|
expectClose(cropBounds.y, visibleAfterFlip.y, 0.01);
|
||||||
|
expectClose(cropBounds.w, visibleAfterFlip.w, 0.01);
|
||||||
|
expectClose(cropBounds.h, visibleAfterFlip.h, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editor geometry aligns crop region after flipX + rotation', () => {
|
||||||
|
// For rotated images, flip changes the AABB position (not size), so we
|
||||||
|
// only check that the editor crop matches the post-flip visible bounds.
|
||||||
|
const data = makeImage({ x: 100, y: 100, w: 400, h: 300, angle: 30, flipX: true, crop: { x: 0.1, y: 0, w: 0.7, h: 1 } });
|
||||||
|
const visibleBounds = getImageWorldBounds(data);
|
||||||
|
|
||||||
|
const eg = getImageEditorGeometry(data);
|
||||||
|
const cropBounds = getBoundsFromPoints(eg.cropWorldCorners);
|
||||||
|
expectClose(cropBounds.x, visibleBounds.x, 0.5);
|
||||||
|
expectClose(cropBounds.y, visibleBounds.y, 0.5);
|
||||||
|
expectClose(cropBounds.w, visibleBounds.w, 0.5);
|
||||||
|
expectClose(cropBounds.h, visibleBounds.h, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crop confirm after flip preserves anchor through re-entry', () => {
|
||||||
|
// Full round-trip: crop → flip → enter crop → confirm same crop → position stable
|
||||||
|
const original = makeImage({ x: 100, y: 100, w: 400, h: 300, crop: { x: 0.2, y: 0, w: 0.6, h: 1 } });
|
||||||
|
const visibleBefore = getImageWorldBounds(original);
|
||||||
|
|
||||||
|
// Flip
|
||||||
|
original.flipX = true;
|
||||||
|
|
||||||
|
// Enter crop mode
|
||||||
|
const eg = getImageEditorGeometry(original);
|
||||||
|
|
||||||
|
// Confirm with same crop (user didn't change anything)
|
||||||
|
const sameCrop = { ...eg.sourceCrop };
|
||||||
|
const displayCrop = getImageDisplayCropRect({ crop: sameCrop, flipX: original.flipX, flipY: original.flipY });
|
||||||
|
const anchorWorld = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
|
||||||
|
|
||||||
|
// Apply crop (as useCanvasSetup does)
|
||||||
|
const result = { ...original, crop: sameCrop };
|
||||||
|
const currentAnchor = imageViewPointToWorld(result, displayCrop.x, displayCrop.y);
|
||||||
|
result.x += anchorWorld.x - currentAnchor.x;
|
||||||
|
result.y += anchorWorld.y - currentAnchor.y;
|
||||||
|
|
||||||
|
// Position should be unchanged
|
||||||
|
const visibleAfter = getImageWorldBounds(result);
|
||||||
|
expectClose(visibleAfter.x, visibleBefore.x, 0.01);
|
||||||
|
expectClose(visibleAfter.y, visibleBefore.y, 0.01);
|
||||||
|
expectClose(visibleAfter.w, visibleBefore.w, 0.01);
|
||||||
|
expectClose(visibleAfter.h, visibleBefore.h, 0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -171,14 +171,26 @@ export function getImageDisplayGeometry(
|
|||||||
export function getImageEditorGeometry(data: ImageObject): ImageEditorGeometry {
|
export function getImageEditorGeometry(data: ImageObject): ImageEditorGeometry {
|
||||||
const sourceCrop = data.crop ? { ...data.crop } : { x: 0, y: 0, w: 1, h: 1 };
|
const sourceCrop = data.crop ? { ...data.crop } : { x: 0, y: 0, w: 1, h: 1 };
|
||||||
const displayCrop = getImageDisplayCropRect(data);
|
const displayCrop = getImageDisplayCropRect(data);
|
||||||
const cropAnchorWorld = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
|
|
||||||
const editorData: ImageObject = {
|
const editorData: ImageObject = {
|
||||||
...data,
|
...data,
|
||||||
crop: undefined,
|
crop: undefined,
|
||||||
};
|
};
|
||||||
const editorAnchorWorld = imageViewPointToWorld(editorData, displayCrop.x, displayCrop.y);
|
|
||||||
editorData.x += cropAnchorWorld.x - editorAnchorWorld.x;
|
// Anchor on the container's local origin: in the cropped sprite, local (0,0)
|
||||||
editorData.y += cropAnchorWorld.y - editorAnchorWorld.y;
|
// corresponds to source pixel (srcRect.x, srcRect.y). Position the editor so
|
||||||
|
// that same source pixel maps to the same world point. This is correct for all
|
||||||
|
// flip/rotation combinations because it operates in local space, not view space.
|
||||||
|
const srcRect = getImageSourceRect(data);
|
||||||
|
const croppedDisplay = getImageDisplayTransform(data);
|
||||||
|
const editorDisplay = getImageDisplayTransform(editorData);
|
||||||
|
const editorSrcOriginWorld = transformPoint(
|
||||||
|
{ x: srcRect.x, y: srcRect.y },
|
||||||
|
{ x: editorDisplay.x, y: editorDisplay.y, sx: editorDisplay.scaleX, sy: editorDisplay.scaleY, angle: editorDisplay.angle },
|
||||||
|
);
|
||||||
|
editorData.x += croppedDisplay.x - editorSrcOriginWorld.x;
|
||||||
|
editorData.y += croppedDisplay.y - editorSrcOriginWorld.y;
|
||||||
|
|
||||||
|
const cropAnchorWorld = imageViewPointToWorld(editorData, displayCrop.x, displayCrop.y);
|
||||||
return {
|
return {
|
||||||
editorData,
|
editorData,
|
||||||
sourceCrop,
|
sourceCrop,
|
||||||
|
|||||||
Reference in New Issue
Block a user