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;
}
}