diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index b65b3ee..7fb218f 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -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); diff --git a/frontend/src/canvas/imageTransforms.ts b/frontend/src/canvas/imageTransforms.ts index 14e3d1d..33d9084 100644 --- a/frontend/src/canvas/imageTransforms.ts +++ b/frontend/src/canvas/imageTransforms.ts @@ -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): 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): 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): 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): { 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): 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, dx: } function getImageLocalPointFromViewNormalized( - data: Pick, + data: Pick, 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, + data: Pick, 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): Point2D[] { - const rect = getVisibleLocalRect(data); +export function getImageVisibleLocalCorners( + data: Pick, +): 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): Point2D[] { + return getImageVisibleLocalCorners(data).map((point) => transformLocalPoint(data, point.x, point.y)); +} + export function getImageWorldBounds(data: Pick): { x: number; y: number; w: number; h: number } { return getBoundsFromPoints(getImageTransformedCorners(data)); } diff --git a/frontend/src/canvas/sprites/ImageSprite.ts b/frontend/src/canvas/sprites/ImageSprite.ts index 3a080b9..8515711 100644 --- a/frontend/src/canvas/sprites/ImageSprite.ts +++ b/frontend/src/canvas/sprites/ImageSprite.ts @@ -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) { diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts index 52e8424..f2629e1 100644 --- a/frontend/src/canvas/sync.ts +++ b/frontend/src/canvas/sync.ts @@ -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);