refboard: centralize image visible frame transforms

This commit is contained in:
Hiren Kangad
2026-03-13 12:58:29 +05:30
parent 768de39463
commit dc283124d7
4 changed files with 123 additions and 58 deletions
+14 -14
View File
@@ -38,7 +38,6 @@ import type {
GroupObject, GroupObject,
StickyObject, StickyObject,
MarkdownObject, MarkdownObject,
SceneObject,
} from './scene-format'; } from './scene-format';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -575,8 +574,9 @@ export class SceneManager {
const startScaleY = obj.scale.y; const startScaleY = obj.scale.y;
const dirX = startScaleX < 0 ? -1 : 1; const dirX = startScaleX < 0 ? -1 : 1;
const dirY = startScaleY < 0 ? -1 : 1; const dirY = startScaleY < 0 ? -1 : 1;
const halfW = item.data.w * Math.abs(item.data.sx) / 2; const bounds = getItemWorldBounds(item);
const halfH = item.data.h * Math.abs(item.data.sy) / 2; const halfW = bounds.w / 2;
const halfH = bounds.h / 2;
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy); const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
scaleSpring.onUpdate = (v) => { scaleSpring.onUpdate = (v) => {
@@ -714,13 +714,11 @@ export class SceneManager {
// Calculate combined bounds center // Calculate combined bounds center
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const child of children) { for (const child of children) {
const d = child.data; const b = getItemWorldBounds(child);
const w = ('w' in d ? (d as SceneObject).w : 0) * d.sx; minX = Math.min(minX, b.x);
const h = ('h' in d ? (d as SceneObject).h : 0) * d.sy; minY = Math.min(minY, b.y);
minX = Math.min(minX, d.x); maxX = Math.max(maxX, b.x + b.w);
minY = Math.min(minY, d.y); maxY = Math.max(maxY, b.y + b.h);
maxX = Math.max(maxX, d.x + w);
maxY = Math.max(maxY, d.y + h);
} }
const cx = (minX + maxX) / 2; const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2; const cy = (minY + maxY) / 2;
@@ -768,8 +766,9 @@ export class SceneManager {
for (const child of children) { for (const child of children) {
const obj = child.displayObject; const obj = child.displayObject;
const dx = cx - (child.data.x + ((child.data as SceneObject).w * child.data.sx) / 2); const childBounds = getItemWorldBounds(child);
const dy = cy - (child.data.y + ((child.data as SceneObject).h * child.data.sy) / 2); const dx = cx - (childBounds.x + childBounds.w / 2);
const dy = cy - (childBounds.y + childBounds.h / 2);
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) continue; // already at center if (dist < 1) continue; // already at center
@@ -852,8 +851,9 @@ export class SceneManager {
freed.push(child); freed.push(child);
// Animate outward from group center // Animate outward from group center
const childCX = worldX + ((child.data as SceneObject).w * child.data.sx) / 2; const childBounds = getItemWorldBounds(child);
const childCY = worldY + ((child.data as SceneObject).h * child.data.sy) / 2; const childCX = childBounds.x + childBounds.w / 2;
const childCY = childBounds.y + childBounds.h / 2;
const dx = childCX - gcx; const dx = childCX - gcx;
const dy = childCY - gcy; const dy = childCY - gcy;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
+84 -25
View File
@@ -24,6 +24,36 @@ export interface RectTransformData {
angle: number; angle: number;
} }
export interface ImageSourceRect {
x: number;
y: number;
w: number;
h: number;
}
export interface ImageVisibleFrame {
sourceRect: ImageSourceRect;
localRect: ImageLocalRect;
display: ImageDisplayTransform;
}
export interface ImageLocalRect {
x: number;
y: number;
w: number;
h: number;
}
/**
* The canonical runtime image model:
* - `sourceRect` is the sampled rectangle inside the original asset
* - `localRect` is the visible rendered frame, always rebased to local 0,0
* - `display` is the final Pixi transform that places that visible frame in world space
*
* All image consumers should derive geometry from this model rather than re-deriving
* crop, flip, and visible bounds independently.
*/
/** /**
* Normalize legacy negative image scales into positive scale magnitude plus flip flags. * Normalize legacy negative image scales into positive scale magnitude plus flip flags.
* This keeps image scene data canonical while preserving visual orientation. * This keeps image scene data canonical while preserving visual orientation.
@@ -39,6 +69,29 @@ export function normalizeImageTransformData(data: ImageObject): void {
} }
} }
export function getImageSourceRect(data: Pick<ImageObject, 'w' | 'h' | 'crop'>): ImageSourceRect {
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 getImageVisibleLocalRect(data: Pick<ImageObject, 'w' | 'h' | 'crop'>): ImageLocalRect {
const sourceRect = getImageSourceRect(data);
return {
x: 0,
y: 0,
w: sourceRect.w,
h: sourceRect.h,
};
}
/** /**
* Compute the actual Pixi display transform for an image from canonical scene data. * 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 * Images render from a top-left local origin, so flip uses a compensating position
@@ -47,29 +100,23 @@ export function normalizeImageTransformData(data: ImageObject): void {
export function getImageDisplayTransform(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): ImageDisplayTransform { export function getImageDisplayTransform(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): ImageDisplayTransform {
const sx = Math.abs(data.sx); const sx = Math.abs(data.sx);
const sy = Math.abs(data.sy); const sy = Math.abs(data.sy);
const visibleW = data.crop ? data.crop.w * data.w : data.w; const visibleRect = getImageVisibleLocalRect(data);
const visibleH = data.crop ? data.crop.h * data.h : data.h;
const scaleX = sx * (data.flipX ? -1 : 1); const scaleX = sx * (data.flipX ? -1 : 1);
const scaleY = sy * (data.flipY ? -1 : 1); const scaleY = sy * (data.flipY ? -1 : 1);
return { return {
x: data.x + (data.flipX ? visibleW * sx : 0), x: data.x + (data.flipX ? visibleRect.w * sx : 0),
y: data.y + (data.flipY ? visibleH * sy : 0), y: data.y + (data.flipY ? visibleRect.h * sy : 0),
scaleX, scaleX,
scaleY, scaleY,
angle: data.angle, angle: data.angle,
}; };
} }
function getVisibleLocalRect(data: Pick<ImageObject, 'w' | 'h' | 'crop'>): { x: number; y: number; w: number; h: number } { export function getImageVisibleFrame(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): ImageVisibleFrame {
const crop = data.crop;
if (!crop) {
return { x: 0, y: 0, w: data.w, h: data.h };
}
return { return {
x: 0, sourceRect: getImageSourceRect(data),
y: 0, localRect: getImageVisibleLocalRect(data),
w: crop.w * data.w, display: getImageDisplayTransform(data),
h: crop.h * data.h,
}; };
} }
@@ -150,23 +197,29 @@ export function offsetImageDataPosition(data: Pick<ImageObject, 'x' | 'y'>, dx:
} }
function getImageLocalPointFromViewNormalized( function getImageLocalPointFromViewNormalized(
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY'>, data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY' | 'crop'>,
viewX: number, viewX: number,
viewY: number, viewY: number,
): Point2D { ): Point2D {
const sourceRect = getImageSourceRect(data);
const sourceX = viewX * data.w;
const sourceY = viewY * data.h;
return { return {
x: (data.flipX ? 1 - viewX : viewX) * data.w, x: data.flipX ? (sourceRect.x + sourceRect.w) - sourceX : sourceX - sourceRect.x,
y: (data.flipY ? 1 - viewY : viewY) * data.h, y: data.flipY ? (sourceRect.y + sourceRect.h) - sourceY : sourceY - sourceRect.y,
}; };
} }
function getImageViewNormalizedFromLocal( function getImageViewNormalizedFromLocal(
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY'>, data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY' | 'crop'>,
localX: number, localX: number,
localY: number, localY: number,
): Point2D { ): Point2D {
const normX = data.w === 0 ? 0 : localX / data.w; const sourceRect = getImageSourceRect(data);
const normY = data.h === 0 ? 0 : localY / data.h; const sourceX = data.flipX ? sourceRect.x + sourceRect.w - localX : sourceRect.x + localX;
const sourceY = data.flipY ? sourceRect.y + sourceRect.h - localY : sourceRect.y + localY;
const normX = data.w === 0 ? 0 : sourceX / data.w;
const normY = data.h === 0 ? 0 : sourceY / data.h;
return { return {
x: data.flipX ? 1 - normX : normX, x: data.flipX ? 1 - normX : normX,
y: data.flipY ? 1 - normY : normY, y: data.flipY ? 1 - normY : normY,
@@ -210,16 +263,22 @@ export function getImageViewRectWorldCorners(
]; ];
} }
export function getImageTransformedCorners(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): Point2D[] { export function getImageVisibleLocalCorners(
const rect = getVisibleLocalRect(data); data: Pick<ImageObject, 'w' | 'h' | 'crop'>,
): Point2D[] {
const rect = getImageVisibleLocalRect(data);
return [ return [
transformLocalPoint(data, rect.x, rect.y), { x: rect.x, y: rect.y },
transformLocalPoint(data, rect.x + rect.w, rect.y), { x: rect.x + rect.w, y: rect.y },
transformLocalPoint(data, rect.x + rect.w, rect.y + rect.h), { x: rect.x + rect.w, y: rect.y + rect.h },
transformLocalPoint(data, rect.x, rect.y + rect.h), { x: rect.x, y: rect.y + rect.h },
]; ];
} }
export function getImageTransformedCorners(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): Point2D[] {
return getImageVisibleLocalCorners(data).map((point) => transformLocalPoint(data, point.x, point.y));
}
export function getImageWorldBounds(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): { x: number; y: number; w: number; h: number } { 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)); return getBoundsFromPoints(getImageTransformedCorners(data));
} }
+21 -19
View File
@@ -1,6 +1,7 @@
import { Container, Sprite, Texture, Graphics, Rectangle } from "pixi.js"; import { Container, Sprite, Texture, Graphics } from "pixi.js";
import { TextureManager } from "../TextureManager"; import { TextureManager } from "../TextureManager";
import type { CropRect } from "../scene-format"; import type { CropRect } from "../scene-format";
import { getImageSourceRect, getImageVisibleLocalRect } from "../imageTransforms";
/** /**
* A Container holding a shadow graphic + sprite with lazy texture loading. * A Container holding a shadow graphic + sprite with lazy texture loading.
@@ -61,36 +62,37 @@ export class ImageSprite extends Container {
get naturalWidth(): number { return this._naturalWidth; } get naturalWidth(): number { return this._naturalWidth; }
get naturalHeight(): number { return this._naturalHeight; } get naturalHeight(): number { return this._naturalHeight; }
private _getVisibleRect(crop: CropRect | undefined): Rectangle {
if (!crop) {
return new Rectangle(0, 0, this._naturalWidth, this._naturalHeight);
}
return new Rectangle(
crop.x * this._naturalWidth,
crop.y * this._naturalHeight,
crop.w * this._naturalWidth,
crop.h * this._naturalHeight,
);
}
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void { private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
const rect = this._getVisibleRect(this._crop); const rect = getImageVisibleLocalRect({
w: this._naturalWidth,
h: this._naturalHeight,
crop: this._crop,
});
this._shadow.clear(); this._shadow.clear();
this._shadow.rect(cfg.offsetX, cfg.offsetY, rect.width, rect.height); this._shadow.rect(cfg.offsetX, cfg.offsetY, rect.w, rect.h);
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha }); this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
} }
private _syncPresentation(): void { private _syncPresentation(): void {
const rect = this._getVisibleRect(this._crop); const sourceRect = getImageSourceRect({
w: this._naturalWidth,
h: this._naturalHeight,
crop: this._crop,
});
const visibleRect = getImageVisibleLocalRect({
w: this._naturalWidth,
h: this._naturalHeight,
crop: this._crop,
});
const needsClip = !!this._crop; const needsClip = !!this._crop;
if (this.placeholder) { if (this.placeholder) {
this.placeholder.clear(); this.placeholder.clear();
this.placeholder.rect(0, 0, rect.width, rect.height).fill(0x2a2a2a); this.placeholder.rect(0, 0, visibleRect.w, visibleRect.h).fill(0x2a2a2a);
} }
if (this._sprite) { if (this._sprite) {
this._sprite.position.set(-rect.x, -rect.y); this._sprite.position.set(-sourceRect.x, -sourceRect.y);
this._sprite.width = this._naturalWidth; this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight; this._sprite.height = this._naturalHeight;
} }
@@ -101,7 +103,7 @@ export class ImageSprite extends Container {
this.addChild(this._cropMask); this.addChild(this._cropMask);
} }
this._cropMask.clear(); this._cropMask.clear();
this._cropMask.rect(0, 0, rect.width, rect.height); this._cropMask.rect(0, 0, visibleRect.w, visibleRect.h);
this._cropMask.fill(0xffffff); this._cropMask.fill(0xffffff);
if (this._sprite) this._sprite.mask = this._cropMask; if (this._sprite) this._sprite.mask = this._cropMask;
} else if (this._cropMask) { } else if (this._cropMask) {
+4
View File
@@ -132,6 +132,8 @@ export function setupSync(
sx: data.sx, sx: data.sx,
sy: data.sy, sy: data.sy,
angle: data.angle, angle: data.angle,
flipX: data.flipX,
flipY: data.flipY,
})); }));
socket.emit('object:transform', { boardId, transforms }); socket.emit('object:transform', { boardId, transforms });
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE); moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
@@ -225,6 +227,8 @@ export function setupSync(
item.data.sx = t.sx; item.data.sx = t.sx;
item.data.sy = t.sy; item.data.sy = t.sy;
item.data.angle = t.angle; item.data.angle = t.angle;
if (typeof t.flipX === 'boolean') item.data.flipX = t.flipX;
if (typeof t.flipY === 'boolean') item.data.flipY = t.flipY;
const obj = item.displayObject; const obj = item.displayObject;
if (item.type === 'image') { if (item.type === 'image') {
applyImageDisplayTransform(obj, item.data as ImageObject); applyImageDisplayTransform(obj, item.data as ImageObject);