refboard: stabilize image flip and crop transforms

This commit is contained in:
Hiren Kangad
2026-03-13 11:21:27 +05:30
parent c40304464f
commit 12410c0098
11 changed files with 359 additions and 98 deletions
+32 -6
View File
@@ -132,6 +132,7 @@ export class CropOverlay extends Container {
if (!this._item) return;
const data = this._item.data;
const zoom = this._viewport.scale.x;
const viewCrop = this._getViewCrop();
// Image world rect
const ix = data.x;
@@ -140,10 +141,10 @@ export class CropOverlay extends Container {
const ih = data.h * data.sy;
// Crop rect in world space
const cx = ix + this._crop.x * iw;
const cy = iy + this._crop.y * ih;
const cw = this._crop.w * iw;
const ch = this._crop.h * ih;
const cx = ix + viewCrop.x * iw;
const cy = iy + viewCrop.y * ih;
const cw = viewCrop.w * iw;
const ch = viewCrop.h * ih;
// Dim: draw 4 rectangles around the crop area
this._dim.clear();
@@ -210,7 +211,7 @@ export class CropOverlay extends Container {
private _onDown(e: FederatedPointerEvent, id: HandleId): void {
e.stopPropagation();
this._drag = { handleId: id, startCrop: { ...this._crop } };
this._drag = { handleId: id, startCrop: this._getViewCrop() };
// Register move/up on the stage (single handler, not per-handle)
this._removeDragListeners();
@@ -288,7 +289,7 @@ export class CropOverlay extends Container {
break;
}
this._crop = crop;
this._setViewCrop(crop);
this._draw();
}
@@ -296,4 +297,29 @@ export class CropOverlay extends Container {
this._drag = null;
this._removeDragListeners();
}
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,
};
}
}
+55 -26
View File
@@ -18,6 +18,15 @@ import { TextSprite } from './sprites/TextSprite';
import { SpringManager, Spring, PRESETS } from './spring';
import { reparentGroupChildren } from './grouping';
import { SpatialGrid } from './SpatialGrid';
import {
applyImageDisplayTransform,
getBoundsFromPoints,
getImageTransformedCorners,
getImageWorldBounds,
getRectTransformedCorners,
normalizeImageTransformData,
transformPoints,
} from './imageTransforms';
import type {
SceneData,
AnySceneObject,
@@ -72,9 +81,10 @@ export function isGroupChild(id: string): boolean {
}
/** Single source of truth for an item's world-space bounding rect.
* Uses data.sx/sy (not obj.scale which may be mid-animation).
* Uses canonical scene data for top-level items.
* For groups: computes the union of children bounds (w/h on group data may be 0).
* For group children: converts local coords to world using parent group transforms. */
* For group children: uses composed Pixi world bounds so parent transforms,
* image flips, and crop masks stay aligned. */
export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
if (item.data.type === 'group') {
return _getGroupWorldBounds(item);
@@ -82,17 +92,22 @@ export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w:
// If this item is a child of a group, convert local → world
const parent = item.displayObject.parent;
if (parent && parent.label && _groupChildIds?.has(item.id)) {
const px = parent.position.x;
const py = parent.position.y;
const psx = parent.scale.x;
const psy = parent.scale.y;
return {
x: px + item.data.x * psx,
y: py + item.data.y * psy,
w: item.data.w * Math.abs(item.data.sx * psx),
h: item.data.h * Math.abs(item.data.sy * psy),
if (parent?.label && _groupChildIds?.has(item.id)) {
const parentTransform = {
x: parent.position.x,
y: parent.position.y,
sx: parent.scale.x,
sy: parent.scale.y,
angle: parent.angle,
};
const localCorners = item.data.type === 'image'
? getImageTransformedCorners(item.data as ImageObject)
: getRectTransformedCorners(item.data);
return getBoundsFromPoints(transformPoints(localCorners, parentTransform));
}
if (item.data.type === 'image') {
return getImageWorldBounds(item.data as ImageObject);
}
return {
@@ -296,6 +311,7 @@ export class SceneManager {
switch (data.type) {
case 'image': {
const imgData = data as ImageObject;
normalizeImageTransformData(imgData);
if (isGifAsset(imgData.asset)) {
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
} else {
@@ -357,9 +373,13 @@ export class SceneManager {
}
// Common properties
displayObject.position.set(data.x, data.y);
displayObject.scale.set(data.sx, data.sy);
displayObject.angle = data.angle;
if (data.type === 'image') {
applyImageDisplayTransform(displayObject, data as ImageObject);
} else {
displayObject.position.set(data.x, data.y);
displayObject.scale.set(data.sx, data.sy);
displayObject.angle = data.angle;
}
displayObject.alpha = data.opacity;
displayObject.visible = data.visible;
displayObject.eventMode = data.locked ? 'none' : 'static';
@@ -401,9 +421,14 @@ export class SceneManager {
_updateItem(item: SceneItem, data: AnySceneObject): void {
const obj = item.displayObject;
obj.position.set(data.x, data.y);
obj.scale.set(data.sx, data.sy);
obj.angle = data.angle;
if (data.type === 'image') {
normalizeImageTransformData(data as ImageObject);
applyImageDisplayTransform(obj, data as ImageObject);
} else {
obj.position.set(data.x, data.y);
obj.scale.set(data.sx, data.sy);
obj.angle = data.angle;
}
obj.alpha = data.opacity;
obj.visible = data.visible;
obj.eventMode = data.locked ? 'none' : 'static';
@@ -532,19 +557,23 @@ export class SceneManager {
this.spatialGrid.remove(id);
this.items.delete(id);
// Scale toward center on removal
const halfW = item.data.w * item.data.sx / 2;
const halfH = item.data.h * item.data.sy / 2;
const startX = item.data.x;
const startY = item.data.y;
// Scale toward center on removal.
const startX = obj.x;
const startY = obj.y;
const startScaleX = obj.scale.x;
const startScaleY = obj.scale.y;
const dirX = startScaleX < 0 ? -1 : 1;
const dirY = startScaleY < 0 ? -1 : 1;
const halfW = item.data.w * Math.abs(item.data.sx) / 2;
const halfH = item.data.h * Math.abs(item.data.sy) / 2;
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
scaleSpring.onUpdate = (v) => {
if (!obj.destroyed) {
obj.scale.set(v * item.data.sx, v * item.data.sy);
obj.scale.set(v * Math.abs(startScaleX) * dirX, v * Math.abs(startScaleY) * dirY);
obj.position.set(
startX + halfW * (1 - v),
startY + halfH * (1 - v),
startX + halfW * (1 - v) * dirX,
startY + halfH * (1 - v) * dirY,
);
}
};
+10 -4
View File
@@ -12,6 +12,8 @@ import { TransformBox } from './TransformBox';
import { SnapGuides } from './SnapGuides';
import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite';
import type { ImageObject } from './scene-format';
import { applyImageDisplayTransform } from './imageTransforms';
// ---------------------------------------------------------------------------
// Constants
@@ -289,10 +291,14 @@ export class SelectionManager {
// Move all selected items by corrected delta and broadcast all together
for (const item of selected) {
item.displayObject.x += ddx;
item.displayObject.y += ddy;
item.data.x = item.displayObject.x;
item.data.y = item.displayObject.y;
item.data.x += ddx;
item.data.y += ddy;
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.x = item.data.x;
item.displayObject.y = item.data.y;
}
}
if (this._onItemsTransform) {
this._onItemsTransform(selected);
+8 -3
View File
@@ -10,6 +10,8 @@ import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pix
import type { Viewport } from 'pixi-viewport';
import { type SceneItem, getItemWorldBounds } from './SceneManager';
import type { SnapGuides } from './SnapGuides';
import { applyImageDisplayTransform } from './imageTransforms';
import type { ImageObject } from './scene-format';
// ---------------------------------------------------------------------------
// Constants
@@ -347,9 +349,12 @@ export class TransformBox extends Container {
item.data.sx = orig.sx * fx;
item.data.sy = orig.sy * fy;
item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.position.set(item.data.x, item.data.y);
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.position.set(item.data.x, item.data.y);
}
this._onItemTransform?.(item);
}
+12 -11
View File
@@ -53,32 +53,32 @@ export async function writeCanvasToClipboard(
y: number;
sx: number;
sy: number;
angle: number;
globalX: number;
globalY: number;
}>();
// Pre-compute world bounds for each item (handles group children with local coords)
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
for (const item of items) {
worldBoundsMap.set(item.id, getItemWorldBounds(item));
}
for (const item of items) {
const obj = item.displayObject;
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
saved.set(item.id, {
parent: obj.parent as Container,
x: obj.x,
y: obj.y,
sx: obj.scale.x,
sy: obj.scale.y,
angle: obj.angle,
globalX: globalOrigin.x,
globalY: globalOrigin.y,
});
// Remove from current parent
obj.parent?.removeChild(obj);
// Position using world bounds (correct for both top-level and group children)
const wb = worldBoundsMap.get(item.id)!;
obj.position.set(wb.x - minX + pad, wb.y - minY + pad);
// Use data scale (not viewport-affected scale)
obj.scale.set(item.data.sx, item.data.sy);
const s = saved.get(item.id)!;
obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
obj.scale.set(s.sx, s.sy);
obj.angle = s.angle;
tempContainer.addChild(obj);
}
@@ -111,6 +111,7 @@ export async function writeCanvasToClipboard(
s.parent.addChild(item.displayObject);
item.displayObject.position.set(s.x, s.y);
item.displayObject.scale.set(s.sx, s.sy);
item.displayObject.angle = s.angle;
}
tempContainer.destroy();
texture?.destroy(true);
+10 -10
View File
@@ -44,24 +44,23 @@ function renderToCanvas(
// Reparent items into temp container
const tempContainer = new Container();
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number }>();
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
for (const item of items) {
worldBoundsMap.set(item.id, getItemWorldBounds(item));
}
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number; angle: number; globalX: number; globalY: number }>();
for (const item of items) {
const obj = item.displayObject;
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
saved.set(item.id, {
parent: obj.parent as Container,
x: obj.x, y: obj.y,
sx: obj.scale.x, sy: obj.scale.y,
angle: obj.angle,
globalX: globalOrigin.x,
globalY: globalOrigin.y,
});
obj.parent?.removeChild(obj);
const wb = worldBoundsMap.get(item.id)!;
obj.position.set(wb.x - minX + pad, wb.y - minY + pad);
obj.scale.set(item.data.sx, item.data.sy);
const s = saved.get(item.id)!;
obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
obj.scale.set(s.sx, s.sy);
obj.angle = s.angle;
tempContainer.addChild(obj);
}
@@ -91,6 +90,7 @@ function renderToCanvas(
s.parent.addChild(item.displayObject);
item.displayObject.position.set(s.x, s.y);
item.displayObject.scale.set(s.sx, s.sy);
item.displayObject.angle = s.angle;
}
tempContainer.destroy();
texture?.destroy(true);
+26 -9
View File
@@ -12,8 +12,9 @@ import type { Viewport } from 'pixi-viewport';
import type { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import type { GroupObject } from './scene-format';
import type { GroupObject, ImageObject } from './scene-format';
import { randomFrameColor } from './sprites/FrameSprite';
import { applyImageDisplayTransform, transformPoint } from './imageTransforms';
/**
* Group selected items into a single group container.
@@ -84,8 +85,12 @@ export function groupItems(
// Reparent display object
obj.parent?.removeChild(obj);
obj.position.set(localX, localY);
groupItem.displayObject.addChild(obj);
if (child.type === 'image') {
applyImageDisplayTransform(obj, child.data as ImageObject);
} else {
obj.position.set(localX, localY);
}
}
selection.clear();
@@ -128,8 +133,12 @@ export function ungroupItems(
const obj = childItem.displayObject;
// Convert local position to world space, accounting for group scale
const worldX = groupX + childItem.data.x * groupSx;
const worldY = groupY + childItem.data.y * groupSy;
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;
@@ -145,9 +154,13 @@ export function ungroupItems(
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
obj.position.set(worldX, worldY);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
if (childItem.type === 'image') {
applyImageDisplayTransform(obj, childItem.data as ImageObject);
} else {
obj.position.set(worldX, worldY);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
}
childIds.push(childId);
}
@@ -188,9 +201,13 @@ export function reparentGroupChildren(scene: SceneManager): void {
// Only reparent if currently in viewport (not already in a group)
if (obj.parent !== groupContainer) {
obj.parent?.removeChild(obj);
// data.x/y are already local coords (saved that way)
obj.position.set(childItem.data.x, childItem.data.y);
groupContainer.addChild(obj);
// data.x/y are already local coords (saved that way)
if (childItem.type === 'image') {
applyImageDisplayTransform(obj, childItem.data as ImageObject);
} else {
obj.position.set(childItem.data.x, childItem.data.y);
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { Container } from 'pixi.js';
import type { CropRect, ImageObject } from './scene-format';
export interface ImageDisplayTransform {
x: number;
y: number;
scaleX: number;
scaleY: number;
angle: number;
}
export interface Point2D {
x: number;
y: number;
}
export interface RectTransformData {
x: number;
y: number;
w: number;
h: number;
sx: number;
sy: number;
angle: number;
}
/**
* Normalize legacy negative image scales into positive scale magnitude plus flip flags.
* This keeps image scene data canonical while preserving visual orientation.
*/
export function normalizeImageTransformData(data: ImageObject): void {
if (data.sx < 0) {
data.sx = Math.abs(data.sx);
data.flipX = !data.flipX;
}
if (data.sy < 0) {
data.sy = Math.abs(data.sy);
data.flipY = !data.flipY;
}
}
/**
* Compute the actual Pixi display transform for an image from canonical scene data.
* Images render from a top-left local origin, so flip uses a compensating position
* shift to keep the visible unrotated bounds anchored at data.x/data.y.
*/
export function getImageDisplayTransform(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>): ImageDisplayTransform {
const sx = Math.abs(data.sx);
const sy = Math.abs(data.sy);
const scaleX = sx * (data.flipX ? -1 : 1);
const scaleY = sy * (data.flipY ? -1 : 1);
return {
x: data.x + (data.flipX ? data.w * sx : 0),
y: data.y + (data.flipY ? data.h * sy : 0),
scaleX,
scaleY,
angle: data.angle,
};
}
function getVisibleLocalRect(data: Pick<ImageObject, 'w' | 'h' | 'crop'>): { x: number; y: number; w: number; h: number } {
const crop = data.crop;
if (!crop) {
return { x: 0, y: 0, w: data.w, h: data.h };
}
return {
x: crop.x * data.w,
y: crop.y * data.h,
w: crop.w * data.w,
h: crop.h * data.h,
};
}
export function applyImageDisplayTransform(displayObject: Container, data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>): void {
const t = getImageDisplayTransform(data);
displayObject.position.set(t.x, t.y);
displayObject.scale.set(t.scaleX, t.scaleY);
displayObject.angle = t.angle;
}
function transformLocalPoint(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>, localX: number, localY: number): Point2D {
const t = getImageDisplayTransform(data);
return transformPoint({ x: localX, y: localY }, {
x: t.x,
y: t.y,
sx: t.scaleX,
sy: t.scaleY,
angle: t.angle,
});
}
export function transformPoint(point: Point2D, transform: Pick<RectTransformData, 'x' | 'y' | 'sx' | 'sy' | 'angle'>): Point2D {
const rad = (transform.angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const dx = point.x * transform.sx;
const dy = point.y * transform.sy;
return {
x: transform.x + dx * cos - dy * sin,
y: transform.y + dx * sin + dy * cos,
};
}
export function transformPoints(points: Point2D[], transform: Pick<RectTransformData, 'x' | 'y' | 'sx' | 'sy' | 'angle'>): Point2D[] {
return points.map((point) => transformPoint(point, transform));
}
export function getRectTransformedCorners(data: RectTransformData): Point2D[] {
return [
transformPoint({ x: 0, y: 0 }, data),
transformPoint({ x: data.w, y: 0 }, data),
transformPoint({ x: data.w, y: data.h }, data),
transformPoint({ x: 0, y: data.h }, data),
];
}
export function getBoundsFromPoints(points: Point2D[]): { x: number; y: number; w: number; h: number } {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const pt of points) {
if (pt.x < minX) minX = pt.x;
if (pt.y < minY) minY = pt.y;
if (pt.x > maxX) maxX = pt.x;
if (pt.y > maxY) maxY = pt.y;
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
export function offsetImageDataPosition(data: Pick<ImageObject, 'x' | 'y'>, dx: number, dy: number): void {
data.x += dx;
data.y += dy;
}
export function getImageTransformedCorners(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): Point2D[] {
const rect = getVisibleLocalRect(data);
return [
transformLocalPoint(data, rect.x, rect.y),
transformLocalPoint(data, rect.x + rect.w, rect.y),
transformLocalPoint(data, rect.x + rect.w, rect.y + rect.h),
transformLocalPoint(data, rect.x, rect.y + rect.h),
];
}
export function getImageWorldBounds(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): { x: number; y: number; w: number; h: number } {
return getBoundsFromPoints(getImageTransformedCorners(data));
}
+29 -5
View File
@@ -7,6 +7,7 @@
import type { SceneItem } from './SceneManager';
import type { ImageObject } from './scene-format';
import { ColorMatrixFilter } from 'pixi.js';
import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTransforms';
// ─── Helpers ───
@@ -60,11 +61,14 @@ function _animTick() {
/** Sync displayObject position from item.data with smooth animation. */
function syncPosition(item: SceneItem): void {
const target = item.type === 'image'
? getImageDisplayTransform(item.data as ImageObject)
: { x: item.data.x, y: item.data.y };
_animTargets.set(item, {
startX: item.displayObject.x,
startY: item.displayObject.y,
endX: item.data.x,
endY: item.data.y,
endX: target.x,
endY: target.y,
t: 0,
});
if (!_animRaf) {
@@ -82,6 +86,12 @@ export function onArrangeAnimationDone(cb: (items: SceneItem[]) => void): void {
/** Sync displayObject scale from item.data. */
function syncScale(item: SceneItem): void {
if (item.type === 'image') {
const t = getImageDisplayTransform(item.data as ImageObject);
item.displayObject.scale.set(t.scaleX, t.scaleY);
item.displayObject.angle = t.angle;
return;
}
item.displayObject.scale.set(item.data.sx, item.data.sy);
}
@@ -358,14 +368,22 @@ function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) {
export function flipHorizontal(objects: SceneItem[]) {
objects.forEach((item) => {
item.data.flipX = !item.data.flipX;
item.displayObject.scale.x = item.data.sx * (item.data.flipX ? -1 : 1);
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.scale.x = item.data.sx * (item.data.flipX ? -1 : 1);
}
});
}
export function flipVertical(objects: SceneItem[]) {
objects.forEach((item) => {
item.data.flipY = !item.data.flipY;
item.displayObject.scale.y = item.data.sy * (item.data.flipY ? -1 : 1);
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.scale.y = item.data.sy * (item.data.flipY ? -1 : 1);
}
});
}
@@ -446,7 +464,13 @@ export function scaleBy(objects: SceneItem[], factor: number) {
export function rotate90(objects: SceneItem[], clockwise: boolean) {
objects.forEach((item) => {
item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360;
item.displayObject.angle = item.data.angle;
if (item.type === 'image') {
const t = getImageDisplayTransform(item.data as ImageObject);
item.displayObject.angle = t.angle;
item.displayObject.scale.set(t.scaleX, t.scaleY);
} else {
item.displayObject.angle = item.data.angle;
}
});
}
+9 -4
View File
@@ -1,6 +1,7 @@
import type { Socket } from 'socket.io-client';
import type { SceneManager, SceneItem } from './SceneManager';
import type { SceneData, AnySceneObject } from './scene-format';
import type { SceneData, AnySceneObject, ImageObject } from './scene-format';
import { applyImageDisplayTransform } from './imageTransforms';
/**
* Sync protocol v3 — Excalidraw-inspired incremental element sync.
@@ -225,9 +226,13 @@ export function setupSync(
item.data.sy = t.sy;
item.data.angle = t.angle;
const obj = item.displayObject;
obj.position.set(t.x, t.y);
obj.scale.set(t.sx, t.sy);
obj.angle = t.angle;
if (item.type === 'image') {
applyImageDisplayTransform(obj, item.data as ImageObject);
} else {
obj.position.set(t.x, t.y);
obj.scale.set(t.sx, t.sy);
obj.angle = t.angle;
}
options?.onRemoteTransform?.(item);
}