refboard: complete image geometry rework — consumer adoption + tests
- Fix grouping.ts ungroup: transform display position (with flip compensation) through group transform, then back-calculate canonical data.x/y. Fixes visual jump for flipped images in rotated groups. - Fix clipboard.ts + export.ts resolution estimation: account for scale when computing effective rendered width for texture ratio. - Add imageTransforms.test.ts: 32 unit tests covering source rect, visible local rect, display transform, display geometry, editor geometry anchor preservation, crop rect round-trips, coordinate conversions, negative scale normalization, and world bounds invariants. - Add vitest as dev dependency with "test" script. - Include canonical geometry layer (imageTransforms.ts) and crop session rewrite (CropOverlay.ts) from prior work. - Add image geometry rework plan document.
This commit is contained in:
@@ -10,7 +10,15 @@ import { Container, Graphics, FederatedPointerEvent, Polygon, Rectangle } from '
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
import type { ImageObject, CropRect } from './scene-format';
|
||||
import { getImageViewRectWorldCorners, imageViewPointToWorld, worldToImageViewPoint } from './imageTransforms';
|
||||
import {
|
||||
applyImageDisplayTransform,
|
||||
displayCropRectToSourceCrop,
|
||||
getImageDisplayCropRect,
|
||||
getImageEditorGeometry,
|
||||
getImageViewRectWorldCorners,
|
||||
imageViewPointToWorld,
|
||||
worldToImageViewPoint,
|
||||
} from './imageTransforms';
|
||||
import { ImageSprite } from './sprites/ImageSprite';
|
||||
|
||||
const HANDLE_SIZE = 8;
|
||||
@@ -37,6 +45,7 @@ export class CropOverlay extends Container {
|
||||
private _handles = new Map<HandleId, Graphics>();
|
||||
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
|
||||
private _originalCrop: CropRect | undefined;
|
||||
private _editorData: ImageObject | null = null;
|
||||
private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null;
|
||||
private _onConfirm: ((item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) | null = null;
|
||||
private _onCancel: (() => void) | null = null;
|
||||
@@ -94,9 +103,12 @@ export class CropOverlay extends Container {
|
||||
this._item = item;
|
||||
const imgData = item.data as ImageObject;
|
||||
this._originalCrop = imgData.crop ? { ...imgData.crop } : undefined;
|
||||
this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 };
|
||||
const editor = getImageEditorGeometry(imgData);
|
||||
this._editorData = editor.editorData;
|
||||
this._crop = editor.sourceCrop;
|
||||
if (item.displayObject instanceof ImageSprite) {
|
||||
item.displayObject.applyCrop(undefined);
|
||||
applyImageDisplayTransform(item.displayObject, this._editorData);
|
||||
}
|
||||
this._viewport.plugins.pause('drag');
|
||||
this.visible = true;
|
||||
@@ -120,8 +132,8 @@ export class CropOverlay extends Container {
|
||||
if (!this._item) return;
|
||||
const item = this._item;
|
||||
const crop = { ...this._crop };
|
||||
const data = this._getWorkingImageData();
|
||||
const viewCrop = this._getViewCrop();
|
||||
const data = this._getEditorData();
|
||||
const viewCrop = getImageDisplayCropRect({ crop, flipX: data.flipX, flipY: data.flipY });
|
||||
const anchorWorld = imageViewPointToWorld(data, viewCrop.x, viewCrop.y);
|
||||
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
|
||||
this._cleanup();
|
||||
@@ -130,9 +142,6 @@ export class CropOverlay extends Container {
|
||||
|
||||
/** Cancel cropping — restore original state. */
|
||||
cancel(): void {
|
||||
if (this._item?.displayObject instanceof ImageSprite) {
|
||||
this._item.displayObject.applyCrop(this._originalCrop);
|
||||
}
|
||||
this._cleanup();
|
||||
this._onCancel?.();
|
||||
}
|
||||
@@ -143,8 +152,10 @@ export class CropOverlay extends Container {
|
||||
private _cleanup(): void {
|
||||
this._removeDomDragListeners();
|
||||
this._viewport.plugins.resume('drag');
|
||||
this._restoreDisplayState();
|
||||
this._item = null;
|
||||
this._originalCrop = undefined;
|
||||
this._editorData = null;
|
||||
this._drag = null;
|
||||
this.visible = false;
|
||||
this._onStateChange?.(false);
|
||||
@@ -162,9 +173,9 @@ export class CropOverlay extends Container {
|
||||
|
||||
private _draw(): void {
|
||||
if (!this._item) return;
|
||||
const data = this._getWorkingImageData();
|
||||
const data = this._getEditorData();
|
||||
const zoom = this._viewport.scale.x;
|
||||
const viewCrop = this._getViewCrop();
|
||||
const viewCrop = getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY });
|
||||
const fullCorners = getImageViewRectWorldCorners(data, { x: 0, y: 0, w: 1, h: 1 });
|
||||
const cropCorners = getImageViewRectWorldCorners(data, viewCrop);
|
||||
|
||||
@@ -241,12 +252,12 @@ export class CropOverlay extends Container {
|
||||
if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') {
|
||||
e.nativeEvent.preventDefault();
|
||||
}
|
||||
const data = this._getWorkingImageData();
|
||||
const data = this._getEditorData();
|
||||
const world = this._viewport.toWorld(e.global.x, e.global.y);
|
||||
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
||||
this._drag = {
|
||||
mode,
|
||||
startCrop: this._getViewCrop(),
|
||||
startCrop: getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }),
|
||||
startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) },
|
||||
};
|
||||
this._removeDomDragListeners();
|
||||
@@ -260,7 +271,7 @@ export class CropOverlay extends Container {
|
||||
private _onMoveScreen(screenX: number, screenY: number): void {
|
||||
if (!this._drag || !this._item) return;
|
||||
|
||||
const data = this._getWorkingImageData();
|
||||
const data = this._getEditorData();
|
||||
const world = this._viewport.toWorld(screenX, screenY);
|
||||
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
||||
const nx = clamp01(viewPoint.x);
|
||||
@@ -272,7 +283,7 @@ export class CropOverlay extends Container {
|
||||
if (mode === 'move') {
|
||||
crop.x = Math.max(0, Math.min(1 - sc.w, sc.x + (nx - startPoint.x)));
|
||||
crop.y = Math.max(0, Math.min(1 - sc.h, sc.y + (ny - startPoint.y)));
|
||||
this._setViewCrop(crop);
|
||||
this._crop = displayCropRectToSourceCrop(data, crop);
|
||||
this._draw();
|
||||
return;
|
||||
}
|
||||
@@ -314,7 +325,7 @@ export class CropOverlay extends Container {
|
||||
break;
|
||||
}
|
||||
|
||||
this._setViewCrop(crop);
|
||||
this._crop = displayCropRectToSourceCrop(data, crop);
|
||||
this._draw();
|
||||
}
|
||||
|
||||
@@ -329,31 +340,6 @@ export class CropOverlay extends Container {
|
||||
this._removeDomDragListeners();
|
||||
}
|
||||
|
||||
private _getViewCrop(): CropRect {
|
||||
if (!this._item) return { ...this._crop };
|
||||
const data = this._item.data as ImageObject;
|
||||
return {
|
||||
x: data.flipX ? 1 - (this._crop.x + this._crop.w) : this._crop.x,
|
||||
y: data.flipY ? 1 - (this._crop.y + this._crop.h) : this._crop.y,
|
||||
w: this._crop.w,
|
||||
h: this._crop.h,
|
||||
};
|
||||
}
|
||||
|
||||
private _setViewCrop(viewCrop: CropRect): void {
|
||||
if (!this._item) {
|
||||
this._crop = { ...viewCrop };
|
||||
return;
|
||||
}
|
||||
const data = this._item.data as ImageObject;
|
||||
this._crop = {
|
||||
x: data.flipX ? 1 - (viewCrop.x + viewCrop.w) : viewCrop.x,
|
||||
y: data.flipY ? 1 - (viewCrop.y + viewCrop.h) : viewCrop.y,
|
||||
w: viewCrop.w,
|
||||
h: viewCrop.h,
|
||||
};
|
||||
}
|
||||
|
||||
private _drawPolygon(graphics: Graphics, points: Array<{ x: number; y: number }>): void {
|
||||
if (points.length === 0) return;
|
||||
graphics.moveTo(points[0].x, points[0].y);
|
||||
@@ -390,11 +376,17 @@ export class CropOverlay extends Container {
|
||||
};
|
||||
}
|
||||
|
||||
private _getWorkingImageData(): ImageObject {
|
||||
const data = this._item!.data as ImageObject;
|
||||
return {
|
||||
...data,
|
||||
crop: undefined,
|
||||
};
|
||||
private _restoreDisplayState(): void {
|
||||
if (!this._item || !(this._item.displayObject instanceof ImageSprite)) return;
|
||||
const data = this._item.data as ImageObject;
|
||||
this._item.displayObject.applyCrop(this._originalCrop);
|
||||
applyImageDisplayTransform(this._item.displayObject, data);
|
||||
}
|
||||
|
||||
private _getEditorData(): ImageObject {
|
||||
if (!this._editorData) {
|
||||
throw new Error('Crop editor data not initialized');
|
||||
}
|
||||
return this._editorData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ export async function writeCanvasToClipboard(
|
||||
for (const item of items) {
|
||||
const tex = (item.displayObject as any)?.texture;
|
||||
const displayW = item.type === 'image'
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
||||
: item.data.w;
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||
: item.data.w * Math.abs(item.data.sx);
|
||||
if (tex && tex.width > 1 && displayW > 1) {
|
||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ function renderToCanvas(
|
||||
for (const item of items) {
|
||||
const tex = (item.displayObject as any)?.texture;
|
||||
const displayW = item.type === 'image'
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
||||
: item.data.w;
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||
: item.data.w * Math.abs(item.data.sx);
|
||||
if (tex && tex.width > 1 && displayW > 1) {
|
||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||
}
|
||||
@@ -182,8 +182,8 @@ export function getExportDimensions(
|
||||
for (const item of items) {
|
||||
const tex = (item.displayObject as any)?.texture;
|
||||
const displayW = item.type === 'image'
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
||||
: item.data.w;
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||
: item.data.w * Math.abs(item.data.sx);
|
||||
if (tex && tex.width > 1 && displayW > 1) {
|
||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
|
||||
import type { SelectionManager } from './SelectionManager';
|
||||
import type { GroupObject, ImageObject } from './scene-format';
|
||||
import { randomFrameColor } from './sprites/FrameSprite';
|
||||
import { applyImageDisplayTransform, transformPoint } from './imageTransforms';
|
||||
import { applyImageDisplayTransform, getImageDisplayTransform, getImageVisibleLocalRect, transformPoint } from './imageTransforms';
|
||||
|
||||
/**
|
||||
* Group selected items into a single group container.
|
||||
@@ -132,32 +132,55 @@ export function ungroupItems(
|
||||
|
||||
const obj = childItem.displayObject;
|
||||
|
||||
// Convert local position to world space, accounting for group scale
|
||||
const worldOrigin = transformPoint(
|
||||
{ x: childItem.data.x, y: childItem.data.y },
|
||||
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
|
||||
);
|
||||
const worldX = worldOrigin.x;
|
||||
const worldY = worldOrigin.y;
|
||||
|
||||
// Propagate group scale to child
|
||||
childItem.data.sx *= groupSx;
|
||||
childItem.data.sy *= groupSy;
|
||||
|
||||
// Propagate group angle to child
|
||||
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
|
||||
|
||||
// Update DATA to store world coords
|
||||
childItem.data.x = worldX;
|
||||
childItem.data.y = worldY;
|
||||
|
||||
// Reparent display object
|
||||
obj.parent?.removeChild(obj);
|
||||
viewport.addChild(obj);
|
||||
if (childItem.type === 'image') {
|
||||
applyImageDisplayTransform(obj, childItem.data as ImageObject);
|
||||
const imgData = childItem.data as ImageObject;
|
||||
|
||||
// For images, transform the display position (with flip compensation) to world,
|
||||
// then back-calculate canonical data.x/y from the new scale/angle.
|
||||
// This is necessary because data.x/y is the canonical anchor, not the visual
|
||||
// top-left — the flip offset must be rotated through the group transform.
|
||||
const localDisplay = getImageDisplayTransform(imgData);
|
||||
const worldDisplay = transformPoint(
|
||||
{ x: localDisplay.x, y: localDisplay.y },
|
||||
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
|
||||
);
|
||||
|
||||
// Propagate group scale and angle
|
||||
imgData.sx *= groupSx;
|
||||
imgData.sy *= groupSy;
|
||||
imgData.angle = (imgData.angle || 0) + groupAngle;
|
||||
|
||||
// Back-calculate canonical x/y: display.x = data.x + flipOffset
|
||||
const newSx = Math.abs(imgData.sx);
|
||||
const newSy = Math.abs(imgData.sy);
|
||||
const visibleRect = getImageVisibleLocalRect(imgData);
|
||||
imgData.x = worldDisplay.x - (imgData.flipX ? visibleRect.w * newSx : 0);
|
||||
imgData.y = worldDisplay.y - (imgData.flipY ? visibleRect.h * newSy : 0);
|
||||
|
||||
// Reparent display object
|
||||
obj.parent?.removeChild(obj);
|
||||
viewport.addChild(obj);
|
||||
applyImageDisplayTransform(obj, imgData);
|
||||
} else {
|
||||
obj.position.set(worldX, worldY);
|
||||
// Convert local position to world space, accounting for group scale
|
||||
const worldOrigin = transformPoint(
|
||||
{ x: childItem.data.x, y: childItem.data.y },
|
||||
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
|
||||
);
|
||||
|
||||
// Propagate group scale and angle
|
||||
childItem.data.sx *= groupSx;
|
||||
childItem.data.sy *= groupSy;
|
||||
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
|
||||
|
||||
// Update DATA to store world coords
|
||||
childItem.data.x = worldOrigin.x;
|
||||
childItem.data.y = worldOrigin.y;
|
||||
|
||||
// Reparent display object
|
||||
obj.parent?.removeChild(obj);
|
||||
viewport.addChild(obj);
|
||||
obj.position.set(worldOrigin.x, worldOrigin.y);
|
||||
obj.scale.set(childItem.data.sx, childItem.data.sy);
|
||||
obj.angle = childItem.data.angle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ImageObject } from './scene-format';
|
||||
import {
|
||||
getImageSourceRect,
|
||||
getImageVisibleLocalRect,
|
||||
getImageDisplayTransform,
|
||||
getImageDisplayGeometry,
|
||||
getImageEditorGeometry,
|
||||
getImageDisplayCropRect,
|
||||
displayCropRectToSourceCrop,
|
||||
imageViewPointToWorld,
|
||||
worldToImageViewPoint,
|
||||
normalizeImageTransformData,
|
||||
getImageTransformedCorners,
|
||||
getImageWorldBounds,
|
||||
} from './imageTransforms';
|
||||
|
||||
/** Helper to build a minimal ImageObject for testing. */
|
||||
function makeImage(overrides: Partial<ImageObject> = {}): ImageObject {
|
||||
return {
|
||||
id: 'test',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 200,
|
||||
h: 100,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
angle: 0,
|
||||
z: 1,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
name: '',
|
||||
visible: true,
|
||||
asset: 'test.png',
|
||||
filters: [],
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectClose(actual: number, expected: number, tolerance = 0.5) {
|
||||
expect(Math.abs(actual - expected)).toBeLessThan(tolerance);
|
||||
}
|
||||
|
||||
// ─── Source Rect ──────────────────────────────────────────────
|
||||
|
||||
describe('getImageSourceRect', () => {
|
||||
it('returns full image when no crop', () => {
|
||||
const r = getImageSourceRect(makeImage());
|
||||
expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 });
|
||||
});
|
||||
|
||||
it('returns cropped region in pixel coords', () => {
|
||||
const r = getImageSourceRect(makeImage({ crop: { x: 0.25, y: 0.1, w: 0.5, h: 0.8 } }));
|
||||
expect(r).toEqual({ x: 50, y: 10, w: 100, h: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Visible Local Rect ──────────────────────────────────────
|
||||
|
||||
describe('getImageVisibleLocalRect', () => {
|
||||
it('full image → same as w/h', () => {
|
||||
const r = getImageVisibleLocalRect(makeImage());
|
||||
expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 });
|
||||
});
|
||||
|
||||
it('cropped → visible dimensions match crop region', () => {
|
||||
const r = getImageVisibleLocalRect(makeImage({ crop: { x: 0.25, y: 0, w: 0.5, h: 1 } }));
|
||||
expect(r).toEqual({ x: 0, y: 0, w: 100, h: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Display Transform ───────────────────────────────────────
|
||||
|
||||
describe('getImageDisplayTransform', () => {
|
||||
it('no flip → position equals data.x/y', () => {
|
||||
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30 }));
|
||||
expect(t.x).toBe(50);
|
||||
expect(t.y).toBe(30);
|
||||
expect(t.scaleX).toBe(1);
|
||||
expect(t.scaleY).toBe(1);
|
||||
});
|
||||
|
||||
it('flipX → position shifts right by visible width', () => {
|
||||
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipX: true }));
|
||||
expect(t.x).toBe(250); // 50 + 200*1
|
||||
expect(t.y).toBe(30);
|
||||
expect(t.scaleX).toBe(-1);
|
||||
});
|
||||
|
||||
it('flipY → position shifts down by visible height', () => {
|
||||
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipY: true }));
|
||||
expect(t.x).toBe(50);
|
||||
expect(t.y).toBe(130); // 30 + 100*1
|
||||
expect(t.scaleY).toBe(-1);
|
||||
});
|
||||
|
||||
it('flipX + crop → shift uses cropped visible width', () => {
|
||||
const data = makeImage({ x: 0, y: 0, flipX: true, crop: { x: 0, y: 0, w: 0.5, h: 1 } });
|
||||
const t = getImageDisplayTransform(data);
|
||||
expect(t.x).toBe(100); // 0 + 100*1 (half width cropped)
|
||||
});
|
||||
|
||||
it('sx=2 + flipX → shift uses scaled visible width', () => {
|
||||
const t = getImageDisplayTransform(makeImage({ x: 0, y: 0, sx: 2, flipX: true }));
|
||||
expect(t.x).toBe(400); // 200 * 2
|
||||
expect(t.scaleX).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Display Geometry ────────────────────────────────────────
|
||||
|
||||
describe('getImageDisplayGeometry', () => {
|
||||
it('world bounds match visible frame for unflipped image', () => {
|
||||
const g = getImageDisplayGeometry(makeImage({ x: 100, y: 50 }));
|
||||
expect(g.worldBounds.x).toBe(100);
|
||||
expect(g.worldBounds.y).toBe(50);
|
||||
expect(g.worldBounds.w).toBe(200);
|
||||
expect(g.worldBounds.h).toBe(100);
|
||||
});
|
||||
|
||||
it('flipped image has same world bounds as unflipped', () => {
|
||||
const normal = getImageDisplayGeometry(makeImage({ x: 100, y: 50 }));
|
||||
const flipped = getImageDisplayGeometry(makeImage({ x: 100, y: 50, flipX: true, flipY: true }));
|
||||
expectClose(flipped.worldBounds.x, normal.worldBounds.x);
|
||||
expectClose(flipped.worldBounds.y, normal.worldBounds.y);
|
||||
expectClose(flipped.worldBounds.w, normal.worldBounds.w);
|
||||
expectClose(flipped.worldBounds.h, normal.worldBounds.h);
|
||||
});
|
||||
|
||||
it('cropped image has smaller world bounds', () => {
|
||||
const g = getImageDisplayGeometry(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } }));
|
||||
expect(g.worldBounds.w).toBe(100); // half
|
||||
expect(g.worldBounds.h).toBe(50); // half
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Editor Geometry (crop session) ──────────────────────────
|
||||
|
||||
describe('getImageEditorGeometry', () => {
|
||||
it('uncropped image → editor shows full image at same position', () => {
|
||||
const data = makeImage({ x: 100, y: 50 });
|
||||
const eg = getImageEditorGeometry(data);
|
||||
expect(eg.sourceCrop).toEqual({ x: 0, y: 0, w: 1, h: 1 });
|
||||
expectClose(eg.editorData.x, 100);
|
||||
expectClose(eg.editorData.y, 50);
|
||||
});
|
||||
|
||||
it('crop entry preserves visible region anchor', () => {
|
||||
const data = makeImage({ x: 100, y: 50, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } });
|
||||
const displayBefore = getImageDisplayGeometry(data);
|
||||
|
||||
const eg = getImageEditorGeometry(data);
|
||||
// The crop anchor world point should match the display geometry's top-left
|
||||
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
|
||||
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
|
||||
});
|
||||
|
||||
it('crop entry after flipX preserves anchor', () => {
|
||||
const data = makeImage({ x: 100, y: 50, flipX: true, crop: { x: 0.25, y: 0, w: 0.5, h: 1 } });
|
||||
const displayBefore = getImageDisplayGeometry(data);
|
||||
|
||||
const eg = getImageEditorGeometry(data);
|
||||
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
|
||||
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
|
||||
});
|
||||
|
||||
it('crop entry after flipY preserves anchor', () => {
|
||||
const data = makeImage({ x: 100, y: 50, flipY: true, crop: { x: 0, y: 0.25, w: 1, h: 0.5 } });
|
||||
const displayBefore = getImageDisplayGeometry(data);
|
||||
|
||||
const eg = getImageEditorGeometry(data);
|
||||
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
|
||||
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
|
||||
});
|
||||
|
||||
it('crop entry after flipX+flipY preserves anchor', () => {
|
||||
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 displayCrop = getImageDisplayCropRect(data);
|
||||
const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
|
||||
|
||||
const eg = getImageEditorGeometry(data);
|
||||
expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01);
|
||||
expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01);
|
||||
|
||||
// The editor's crop region should match the original crop region in world space
|
||||
const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
|
||||
expectClose(editorCropCorner.x, expectedAnchor.x, 1);
|
||||
expectClose(editorCropCorner.y, expectedAnchor.y, 1);
|
||||
});
|
||||
|
||||
it('crop entry after rotate preserves anchor', () => {
|
||||
const data = makeImage({ x: 100, y: 50, angle: 45, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } });
|
||||
const displayCrop = getImageDisplayCropRect(data);
|
||||
const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
|
||||
|
||||
const eg = getImageEditorGeometry(data);
|
||||
expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01);
|
||||
expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01);
|
||||
|
||||
// The editor's crop region should match the original crop region in world space
|
||||
const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
|
||||
expectClose(editorCropCorner.x, expectedAnchor.x, 1);
|
||||
expectClose(editorCropCorner.y, expectedAnchor.y, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Crop Rect Conversions ───────────────────────────────────
|
||||
|
||||
describe('crop rect conversions', () => {
|
||||
it('round-trip: display → source → display', () => {
|
||||
const data = makeImage({ flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 } });
|
||||
const display = getImageDisplayCropRect(data);
|
||||
const source = displayCropRectToSourceCrop(data, display);
|
||||
expectClose(source.x, data.crop!.x);
|
||||
expectClose(source.y, data.crop!.y);
|
||||
expectClose(source.w, data.crop!.w);
|
||||
expectClose(source.h, data.crop!.h);
|
||||
});
|
||||
|
||||
it('no flip → display crop equals source crop', () => {
|
||||
const data = makeImage({ crop: { x: 0.2, y: 0.3, w: 0.4, h: 0.5 } });
|
||||
const display = getImageDisplayCropRect(data);
|
||||
expect(display.x).toBe(0.2);
|
||||
expect(display.y).toBe(0.3);
|
||||
});
|
||||
|
||||
it('flipX mirrors crop horizontally', () => {
|
||||
const data = makeImage({ flipX: true, crop: { x: 0.1, y: 0, w: 0.3, h: 1 } });
|
||||
const display = getImageDisplayCropRect(data);
|
||||
expectClose(display.x, 0.6); // 1 - (0.1 + 0.3)
|
||||
expect(display.w).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Coordinate Round-Trips ──────────────────────────────────
|
||||
|
||||
describe('coordinate conversions', () => {
|
||||
it('source → world → source round-trip (no flip)', () => {
|
||||
const data = makeImage({ x: 100, y: 50 });
|
||||
const world = imageViewPointToWorld(data, 0.5, 0.5);
|
||||
const back = worldToImageViewPoint(data, world.x, world.y);
|
||||
expectClose(back.x, 0.5);
|
||||
expectClose(back.y, 0.5);
|
||||
});
|
||||
|
||||
it('source → world → source round-trip (flipX)', () => {
|
||||
const data = makeImage({ x: 100, y: 50, flipX: true });
|
||||
const world = imageViewPointToWorld(data, 0.3, 0.7);
|
||||
const back = worldToImageViewPoint(data, world.x, world.y);
|
||||
expectClose(back.x, 0.3);
|
||||
expectClose(back.y, 0.7);
|
||||
});
|
||||
|
||||
it('source → world → source round-trip (flipX + flipY + crop)', () => {
|
||||
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 world = imageViewPointToWorld(data, 0.4, 0.5);
|
||||
const back = worldToImageViewPoint(data, world.x, world.y);
|
||||
expectClose(back.x, 0.4);
|
||||
expectClose(back.y, 0.5);
|
||||
});
|
||||
|
||||
it('source → world → source round-trip (rotated)', () => {
|
||||
const data = makeImage({ x: 100, y: 50, angle: 90 });
|
||||
const world = imageViewPointToWorld(data, 0.25, 0.75);
|
||||
const back = worldToImageViewPoint(data, world.x, world.y);
|
||||
expectClose(back.x, 0.25);
|
||||
expectClose(back.y, 0.75);
|
||||
});
|
||||
|
||||
it('source → world → source round-trip (rotated + flipped + cropped)', () => {
|
||||
const data = makeImage({ x: 100, y: 50, angle: 45, flipX: true, crop: { x: 0.2, y: 0.1, w: 0.6, h: 0.8 } });
|
||||
const world = imageViewPointToWorld(data, 0.5, 0.5);
|
||||
const back = worldToImageViewPoint(data, world.x, world.y);
|
||||
expectClose(back.x, 0.5, 0.01);
|
||||
expectClose(back.y, 0.5, 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Negative Scale Normalization ────────────────────────────
|
||||
|
||||
describe('normalizeImageTransformData', () => {
|
||||
it('normalizes negative sx to positive + flipX', () => {
|
||||
const data = makeImage({ sx: -2, flipX: false });
|
||||
normalizeImageTransformData(data);
|
||||
expect(data.sx).toBe(2);
|
||||
expect(data.flipX).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes negative sy to positive + flipY', () => {
|
||||
const data = makeImage({ sy: -1.5, flipY: false });
|
||||
normalizeImageTransformData(data);
|
||||
expect(data.sy).toBe(1.5);
|
||||
expect(data.flipY).toBe(true);
|
||||
});
|
||||
|
||||
it('double negative sx toggles existing flipX', () => {
|
||||
const data = makeImage({ sx: -1, flipX: true });
|
||||
normalizeImageTransformData(data);
|
||||
expect(data.sx).toBe(1);
|
||||
expect(data.flipX).toBe(false); // was true, negated
|
||||
});
|
||||
|
||||
it('positive scale is unchanged', () => {
|
||||
const data = makeImage({ sx: 2, sy: 3, flipX: true, flipY: false });
|
||||
normalizeImageTransformData(data);
|
||||
expect(data.sx).toBe(2);
|
||||
expect(data.sy).toBe(3);
|
||||
expect(data.flipX).toBe(true);
|
||||
expect(data.flipY).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── World Bounds Invariants ─────────────────────────────────
|
||||
|
||||
describe('world bounds invariants', () => {
|
||||
it('flip does not change world bounds', () => {
|
||||
const base = makeImage({ x: 50, y: 50, sx: 1.5, sy: 1.5 });
|
||||
const bounds0 = getImageWorldBounds(base);
|
||||
|
||||
const fx = makeImage({ ...base, flipX: true });
|
||||
const boundsX = getImageWorldBounds(fx);
|
||||
expectClose(boundsX.x, bounds0.x);
|
||||
expectClose(boundsX.w, bounds0.w);
|
||||
|
||||
const fy = makeImage({ ...base, flipY: true });
|
||||
const boundsY = getImageWorldBounds(fy);
|
||||
expectClose(boundsY.y, bounds0.y);
|
||||
expectClose(boundsY.h, bounds0.h);
|
||||
});
|
||||
|
||||
it('crop reduces world bounds proportionally', () => {
|
||||
const full = getImageWorldBounds(makeImage({ x: 0, y: 0 }));
|
||||
const half = getImageWorldBounds(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } }));
|
||||
expectClose(half.w, full.w / 2);
|
||||
expectClose(half.h, full.h / 2);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,11 @@ export interface ImageVisibleFrame {
|
||||
display: ImageDisplayTransform;
|
||||
}
|
||||
|
||||
export interface ImageDisplayGeometry extends ImageVisibleFrame {
|
||||
worldCorners: Point2D[];
|
||||
worldBounds: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
export interface ImageLocalRect {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -44,6 +49,15 @@ export interface ImageLocalRect {
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ImageEditorGeometry {
|
||||
editorData: ImageObject;
|
||||
sourceCrop: CropRect;
|
||||
displayCrop: CropRect;
|
||||
fullWorldCorners: Point2D[];
|
||||
cropWorldCorners: Point2D[];
|
||||
cropAnchorWorld: Point2D;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical runtime image model:
|
||||
* - `sourceRect` is the sampled rectangle inside the original asset
|
||||
@@ -120,6 +134,61 @@ export function getImageVisibleFrame(data: Pick<ImageObject, 'x' | 'y' | 'w' | '
|
||||
};
|
||||
}
|
||||
|
||||
export function getImageDisplayCropRect(data: Pick<ImageObject, 'crop' | 'flipX' | 'flipY'>): CropRect {
|
||||
const crop = data.crop ?? { x: 0, y: 0, w: 1, h: 1 };
|
||||
return {
|
||||
x: data.flipX ? 1 - (crop.x + crop.w) : crop.x,
|
||||
y: data.flipY ? 1 - (crop.y + crop.h) : crop.y,
|
||||
w: crop.w,
|
||||
h: crop.h,
|
||||
};
|
||||
}
|
||||
|
||||
export function displayCropRectToSourceCrop(
|
||||
data: Pick<ImageObject, 'flipX' | 'flipY'>,
|
||||
displayCrop: CropRect,
|
||||
): CropRect {
|
||||
return {
|
||||
x: data.flipX ? 1 - (displayCrop.x + displayCrop.w) : displayCrop.x,
|
||||
y: data.flipY ? 1 - (displayCrop.y + displayCrop.h) : displayCrop.y,
|
||||
w: displayCrop.w,
|
||||
h: displayCrop.h,
|
||||
};
|
||||
}
|
||||
|
||||
export function getImageDisplayGeometry(
|
||||
data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>,
|
||||
): ImageDisplayGeometry {
|
||||
const frame = getImageVisibleFrame(data);
|
||||
const worldCorners = getImageTransformedCorners(data);
|
||||
return {
|
||||
...frame,
|
||||
worldCorners,
|
||||
worldBounds: getBoundsFromPoints(worldCorners),
|
||||
};
|
||||
}
|
||||
|
||||
export function getImageEditorGeometry(data: ImageObject): ImageEditorGeometry {
|
||||
const sourceCrop = data.crop ? { ...data.crop } : { x: 0, y: 0, w: 1, h: 1 };
|
||||
const displayCrop = getImageDisplayCropRect(data);
|
||||
const cropAnchorWorld = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
|
||||
const editorData: ImageObject = {
|
||||
...data,
|
||||
crop: undefined,
|
||||
};
|
||||
const editorAnchorWorld = imageViewPointToWorld(editorData, displayCrop.x, displayCrop.y);
|
||||
editorData.x += cropAnchorWorld.x - editorAnchorWorld.x;
|
||||
editorData.y += cropAnchorWorld.y - editorAnchorWorld.y;
|
||||
return {
|
||||
editorData,
|
||||
sourceCrop,
|
||||
displayCrop,
|
||||
fullWorldCorners: getImageViewRectWorldCorners(editorData, { x: 0, y: 0, w: 1, h: 1 }),
|
||||
cropWorldCorners: getImageViewRectWorldCorners(editorData, displayCrop),
|
||||
cropAnchorWorld,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyImageDisplayTransform(displayObject: Container, data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): void {
|
||||
const t = getImageDisplayTransform(data);
|
||||
displayObject.position.set(t.x, t.y);
|
||||
|
||||
Reference in New Issue
Block a user