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:
Hiren Kangad
2026-03-13 13:37:42 +05:30
parent c874e34577
commit 9a4edd90c7
8 changed files with 867 additions and 79 deletions
+37 -45
View File
@@ -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;
}
}
+2 -2
View File
@@ -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);
}
+4 -4
View File
@@ -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);
}
+48 -25
View File
@@ -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;
}
+341
View File
@@ -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);
});
});
+69
View File
@@ -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);