refboard: centralize image visible frame transforms
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user