refboard: centralize image visible frame transforms
This commit is contained in:
@@ -38,7 +38,6 @@ import type {
|
||||
GroupObject,
|
||||
StickyObject,
|
||||
MarkdownObject,
|
||||
SceneObject,
|
||||
} from './scene-format';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -575,8 +574,9 @@ export class SceneManager {
|
||||
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 bounds = getItemWorldBounds(item);
|
||||
const halfW = bounds.w / 2;
|
||||
const halfH = bounds.h / 2;
|
||||
|
||||
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
|
||||
scaleSpring.onUpdate = (v) => {
|
||||
@@ -714,13 +714,11 @@ export class SceneManager {
|
||||
// Calculate combined bounds center
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const child of children) {
|
||||
const d = child.data;
|
||||
const w = ('w' in d ? (d as SceneObject).w : 0) * d.sx;
|
||||
const h = ('h' in d ? (d as SceneObject).h : 0) * d.sy;
|
||||
minX = Math.min(minX, d.x);
|
||||
minY = Math.min(minY, d.y);
|
||||
maxX = Math.max(maxX, d.x + w);
|
||||
maxY = Math.max(maxY, d.y + h);
|
||||
const b = getItemWorldBounds(child);
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
@@ -768,8 +766,9 @@ export class SceneManager {
|
||||
|
||||
for (const child of children) {
|
||||
const obj = child.displayObject;
|
||||
const dx = cx - (child.data.x + ((child.data as SceneObject).w * child.data.sx) / 2);
|
||||
const dy = cy - (child.data.y + ((child.data as SceneObject).h * child.data.sy) / 2);
|
||||
const childBounds = getItemWorldBounds(child);
|
||||
const dx = cx - (childBounds.x + childBounds.w / 2);
|
||||
const dy = cy - (childBounds.y + childBounds.h / 2);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 1) continue; // already at center
|
||||
@@ -852,8 +851,9 @@ export class SceneManager {
|
||||
freed.push(child);
|
||||
|
||||
// Animate outward from group center
|
||||
const childCX = worldX + ((child.data as SceneObject).w * child.data.sx) / 2;
|
||||
const childCY = worldY + ((child.data as SceneObject).h * child.data.sy) / 2;
|
||||
const childBounds = getItemWorldBounds(child);
|
||||
const childCX = childBounds.x + childBounds.w / 2;
|
||||
const childCY = childBounds.y + childBounds.h / 2;
|
||||
const dx = childCX - gcx;
|
||||
const dy = childCY - gcy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
@@ -24,6 +24,36 @@ export interface RectTransformData {
|
||||
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.
|
||||
* 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.
|
||||
* 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 {
|
||||
const sx = Math.abs(data.sx);
|
||||
const sy = Math.abs(data.sy);
|
||||
const visibleW = data.crop ? data.crop.w * data.w : data.w;
|
||||
const visibleH = data.crop ? data.crop.h * data.h : data.h;
|
||||
const visibleRect = getImageVisibleLocalRect(data);
|
||||
const scaleX = sx * (data.flipX ? -1 : 1);
|
||||
const scaleY = sy * (data.flipY ? -1 : 1);
|
||||
return {
|
||||
x: data.x + (data.flipX ? visibleW * sx : 0),
|
||||
y: data.y + (data.flipY ? visibleH * sy : 0),
|
||||
x: data.x + (data.flipX ? visibleRect.w * sx : 0),
|
||||
y: data.y + (data.flipY ? visibleRect.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 };
|
||||
}
|
||||
export function getImageVisibleFrame(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): ImageVisibleFrame {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: crop.w * data.w,
|
||||
h: crop.h * data.h,
|
||||
sourceRect: getImageSourceRect(data),
|
||||
localRect: getImageVisibleLocalRect(data),
|
||||
display: getImageDisplayTransform(data),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,23 +197,29 @@ export function offsetImageDataPosition(data: Pick<ImageObject, 'x' | 'y'>, dx:
|
||||
}
|
||||
|
||||
function getImageLocalPointFromViewNormalized(
|
||||
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY'>,
|
||||
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY' | 'crop'>,
|
||||
viewX: number,
|
||||
viewY: number,
|
||||
): Point2D {
|
||||
const sourceRect = getImageSourceRect(data);
|
||||
const sourceX = viewX * data.w;
|
||||
const sourceY = viewY * data.h;
|
||||
return {
|
||||
x: (data.flipX ? 1 - viewX : viewX) * data.w,
|
||||
y: (data.flipY ? 1 - viewY : viewY) * data.h,
|
||||
x: data.flipX ? (sourceRect.x + sourceRect.w) - sourceX : sourceX - sourceRect.x,
|
||||
y: data.flipY ? (sourceRect.y + sourceRect.h) - sourceY : sourceY - sourceRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
function getImageViewNormalizedFromLocal(
|
||||
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY'>,
|
||||
data: Pick<ImageObject, 'w' | 'h' | 'flipX' | 'flipY' | 'crop'>,
|
||||
localX: number,
|
||||
localY: number,
|
||||
): Point2D {
|
||||
const normX = data.w === 0 ? 0 : localX / data.w;
|
||||
const normY = data.h === 0 ? 0 : localY / data.h;
|
||||
const sourceRect = getImageSourceRect(data);
|
||||
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 {
|
||||
x: data.flipX ? 1 - normX : normX,
|
||||
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[] {
|
||||
const rect = getVisibleLocalRect(data);
|
||||
export function getImageVisibleLocalCorners(
|
||||
data: Pick<ImageObject, 'w' | 'h' | 'crop'>,
|
||||
): Point2D[] {
|
||||
const rect = getImageVisibleLocalRect(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),
|
||||
{ x: rect.x, y: rect.y },
|
||||
{ x: rect.x + rect.w, y: rect.y },
|
||||
{ x: rect.x + rect.w, y: 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 } {
|
||||
return getBoundsFromPoints(getImageTransformedCorners(data));
|
||||
}
|
||||
|
||||
@@ -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 type { CropRect } from "../scene-format";
|
||||
import { getImageSourceRect, getImageVisibleLocalRect } from "../imageTransforms";
|
||||
|
||||
/**
|
||||
* 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 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 {
|
||||
const rect = this._getVisibleRect(this._crop);
|
||||
const rect = getImageVisibleLocalRect({
|
||||
w: this._naturalWidth,
|
||||
h: this._naturalHeight,
|
||||
crop: this._crop,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (this.placeholder) {
|
||||
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) {
|
||||
this._sprite.position.set(-rect.x, -rect.y);
|
||||
this._sprite.position.set(-sourceRect.x, -sourceRect.y);
|
||||
this._sprite.width = this._naturalWidth;
|
||||
this._sprite.height = this._naturalHeight;
|
||||
}
|
||||
@@ -101,7 +103,7 @@ export class ImageSprite extends Container {
|
||||
this.addChild(this._cropMask);
|
||||
}
|
||||
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);
|
||||
if (this._sprite) this._sprite.mask = this._cropMask;
|
||||
} else if (this._cropMask) {
|
||||
|
||||
@@ -132,6 +132,8 @@ export function setupSync(
|
||||
sx: data.sx,
|
||||
sy: data.sy,
|
||||
angle: data.angle,
|
||||
flipX: data.flipX,
|
||||
flipY: data.flipY,
|
||||
}));
|
||||
socket.emit('object:transform', { boardId, transforms });
|
||||
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
|
||||
@@ -225,6 +227,8 @@ export function setupSync(
|
||||
item.data.sx = t.sx;
|
||||
item.data.sy = t.sy;
|
||||
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;
|
||||
if (item.type === 'image') {
|
||||
applyImageDisplayTransform(obj, item.data as ImageObject);
|
||||
|
||||
Reference in New Issue
Block a user