fix(refboard): coordinate space bugs + legacy texture fallback

- TransformBox: use item.data (world-space) instead of getBounds() (screen-space)
  for correct handle positioning regardless of viewport pan/zoom
- TransformBox: scale drag sensitivity relative to object size, not fixed 500px
- TransformBox: compensate drag deltas for viewport zoom level
- TransformBox: fix corner/edge handles to keep opposite edge fixed during resize
- SelectionManager: use world-space hit testing instead of screen-space getBounds()
- SelectionManager: fix rubber band selection to use world-space item bounds
- TextureManager: detect legacy asset keys (with file extension) vs new LOD keys
  and load original file directly for pre-migration images (GPU handles scaling)
This commit is contained in:
Hiren Kangad
2026-03-09 23:45:58 +05:30
parent 21b2a433c7
commit 9542daa6cc
3 changed files with 110 additions and 57 deletions
+19 -14
View File
@@ -69,6 +69,7 @@ export class SelectionManager {
// Transform box // Transform box
this.transformBox = new TransformBox(); this.transformBox = new TransformBox();
this.transformBox.setViewport(viewport);
this._overlay.addChild(this.transformBox); this._overlay.addChild(this.transformBox);
// Bind events on viewport // Bind events on viewport
@@ -131,7 +132,7 @@ export class SelectionManager {
// -- Hit Testing ---------------------------------------------------------- // -- Hit Testing ----------------------------------------------------------
/** Check all scene items in reverse z-order; return first whose bounds contain (wx, wy). */ /** Check all scene items in reverse z-order; return first whose world bounds contain (wx, wy). */
_hitTest(wx: number, wy: number): SceneItem | null { _hitTest(wx: number, wy: number): SceneItem | null {
const all = this._scene.getAllItems(); const all = this._scene.getAllItems();
// Sort by z descending (topmost first) // Sort by z descending (topmost first)
@@ -141,13 +142,13 @@ export class SelectionManager {
if (item.data.locked) continue; if (item.data.locked) continue;
if (!item.data.visible) continue; if (!item.data.visible) continue;
const bounds = item.displayObject.getBounds(); // Use item.data (world-space) instead of getBounds() (screen-space)
if ( const ix = item.data.x;
bounds.x <= wx && const iy = item.data.y;
wx <= bounds.x + bounds.width && const iw = item.data.w * Math.abs(item.data.sx);
bounds.y <= wy && const ih = item.data.h * Math.abs(item.data.sy);
wy <= bounds.y + bounds.height
) { if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
return item; return item;
} }
} }
@@ -344,14 +345,18 @@ export class SelectionManager {
for (const item of this._scene.getAllItems()) { for (const item of this._scene.getAllItems()) {
if (item.data.locked || !item.data.visible) continue; if (item.data.locked || !item.data.visible) continue;
const bounds = item.displayObject.getBounds(); // Use world-space data instead of screen-space getBounds()
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
// Check intersection (not containment) between rubber band rect and item bounds // Check intersection between rubber band rect and item world bounds
const intersects = const intersects =
rx < bounds.x + bounds.width && rx < ix + iw &&
rx + rw > bounds.x && rx + rw > ix &&
ry < bounds.y + bounds.height && ry < iy + ih &&
ry + rh > bounds.y; ry + rh > iy;
if (intersects) { if (intersects) {
this.selectedIds.add(item.id); this.selectedIds.add(item.id);
+32 -5
View File
@@ -21,18 +21,39 @@ export class TextureManager {
return "medium"; return "medium";
} }
/**
* Detect whether an asset key is a new LOD-format key (no extension)
* or an old pre-migration path (has file extension).
*/
private _isLegacyAsset(assetKey: string): boolean {
// New LOD keys look like: boards/{boardId}/{imageId} (no extension)
// Old keys look like: boards/{boardId}/{imageId}.png or full URLs
return /\.\w{2,5}$/.test(assetKey) || assetKey.startsWith('http') || assetKey.startsWith('/api/');
}
/** Build the URL for a given asset + tier. */ /** Build the URL for a given asset + tier. */
urlForAsset(assetKey: string, tier: LODTier): string { urlForAsset(assetKey: string, tier: LODTier): string {
if (this._isLegacyAsset(assetKey)) {
// Legacy: load original file directly (GPU handles scaling)
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
return assetKey;
}
return `/api/images/${assetKey}`;
}
// New LOD format: boards/{boardId}/{imageId}/thumb.webp
return `/api/images/${assetKey}/${tier}.webp`; return `/api/images/${assetKey}/${tier}.webp`;
} }
/** /**
* Load a texture for the given asset and LOD tier. * Load a texture for the given asset and LOD tier.
* Returns a cached texture if available, otherwise fetches it, * For legacy assets (pre-migration), loads the original file directly —
* estimates GPU memory, and evicts LRU entries if over budget. * the GPU handles downscaling naturally.
* For new assets, loads the appropriate LOD tier with fallback.
*/ */
async load(assetKey: string, tier: LODTier): Promise<Texture> { async load(assetKey: string, tier: LODTier): Promise<Texture> {
const key = `${assetKey}:${tier}`; // For legacy assets, all tiers resolve to the same URL
const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier;
const key = `${assetKey}:${effectiveTier}`;
const existing = this.cache.get(key); const existing = this.cache.get(key);
if (existing) { if (existing) {
@@ -40,8 +61,14 @@ export class TextureManager {
return existing.texture; return existing.texture;
} }
const url = this.urlForAsset(assetKey, tier); const url = this.urlForAsset(assetKey, effectiveTier);
const texture: Texture = await Assets.load(url); let texture: Texture;
try {
texture = await Assets.load(url);
} catch {
console.warn(`[TextureManager] Failed to load ${url}`);
return Texture.EMPTY;
}
const w = texture.source.width ?? 256; const w = texture.source.width ?? 256;
const h = texture.source.height ?? 256; const h = texture.source.height ?? 256;
+59 -38
View File
@@ -7,6 +7,7 @@
*/ */
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js'; import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager'; import type { SceneItem } from './SceneManager';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -58,6 +59,7 @@ export class TransformBox extends Container {
private _items: SceneItem[] = []; private _items: SceneItem[] = [];
private _bounds = { x: 0, y: 0, w: 0, h: 0 }; private _bounds = { x: 0, y: 0, w: 0, h: 0 };
private _drag: DragState | null = null; private _drag: DragState | null = null;
private _viewport: Viewport | null = null;
constructor() { constructor() {
super(); super();
@@ -92,6 +94,11 @@ export class TransformBox extends Container {
// -- Public API ----------------------------------------------------------- // -- Public API -----------------------------------------------------------
/** Set the viewport reference for zoom-aware drag calculations. */
setViewport(viewport: Viewport): void {
this._viewport = viewport;
}
/** Update the transform box to wrap the given items. Hides if empty. */ /** Update the transform box to wrap the given items. Hides if empty. */
update(items: SceneItem[]): void { update(items: SceneItem[]): void {
this._items = items; this._items = items;
@@ -101,18 +108,22 @@ export class TransformBox extends Container {
return; return;
} }
// Compute combined bounding rect in world space // Compute combined bounding rect in WORLD space using item data
// (not getBounds() which returns screen-space and causes offset)
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
for (const item of items) { for (const item of items) {
const b = item.displayObject.getBounds(); const ix = item.data.x;
if (b.x < minX) minX = b.x; const iy = item.data.y;
if (b.y < minY) minY = b.y; const iw = item.data.w * Math.abs(item.data.sx);
if (b.x + b.width > maxX) maxX = b.x + b.width; const ih = item.data.h * Math.abs(item.data.sy);
if (b.y + b.height > maxY) maxY = b.y + b.height; if (ix < minX) minX = ix;
if (iy < minY) minY = iy;
if (ix + iw > maxX) maxX = ix + iw;
if (iy + ih > maxY) maxY = iy + ih;
} }
this._bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; this._bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
@@ -200,76 +211,86 @@ export class TransformBox extends Container {
private _onHandleMove(e: FederatedPointerEvent): void { private _onHandleMove(e: FederatedPointerEvent): void {
if (!this._drag) return; if (!this._drag) return;
const dx = e.global.x - this._drag.startX; // Convert screen-space deltas to world-space by dividing by viewport zoom
const dy = e.global.y - this._drag.startY; const zoom = this._viewport?.scale.x ?? 1;
const dx = (e.global.x - this._drag.startX) / zoom;
const dy = (e.global.y - this._drag.startY) / zoom;
const { handleId, origTransforms } = this._drag; const { handleId, origTransforms } = this._drag;
// Use bounding box size as reference for scale sensitivity
const { w: bw, h: bh } = this._bounds;
const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers
for (const item of this._items) { for (const item of this._items) {
const orig = origTransforms.get(item.id); const orig = origTransforms.get(item.id);
if (!orig) continue; if (!orig) continue;
switch (handleId) { switch (handleId) {
case 'br': { case 'br': {
// Proportional scale // Proportional scale — drag distance relative to object size
const factor = 1 + (dx + dy) / 500; const factor = 1 + (dx + dy) / refSize;
const clampedFactor = Math.max(0.1, factor); const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor; item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor; item.data.sy = orig.sy * clampedFactor;
break; break;
} }
case 'mr': { case 'mr': {
// Horizontal scale only const factor = 1 + dx / (bw || refSize);
const factor = 1 + dx / 500; item.data.sx = orig.sx * Math.max(0.05, factor);
item.data.sx = orig.sx * Math.max(0.1, factor);
break; break;
} }
case 'bc': { case 'bc': {
// Vertical scale only const factor = 1 + dy / (bh || refSize);
const factor = 1 + dy / 500; item.data.sy = orig.sy * Math.max(0.05, factor);
item.data.sy = orig.sy * Math.max(0.1, factor);
break; break;
} }
case 'tl': { case 'tl': {
// Proportional scale + reposition (opposite corner stays fixed) // Proportional scale + reposition (bottom-right stays fixed)
const factor = 1 - (dx + dy) / 500; const factor = 1 - (dx + dy) / refSize;
const clampedFactor = Math.max(0.1, factor); const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor; item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor; item.data.sy = orig.sy * clampedFactor;
// Shift position so bottom-right stays fixed const dw = (item.data.sx - orig.sx) * item.data.w;
const dw = (item.data.sx - orig.sx) * (item.data.w ?? 0); const dh = (item.data.sy - orig.sy) * item.data.h;
const dh = (item.data.sy - orig.sy) * (item.data.h ?? 0);
item.data.x = orig.x - dw; item.data.x = orig.x - dw;
item.data.y = orig.y - dh; item.data.y = orig.y - dh;
break; break;
} }
case 'rot': { case 'rot': {
// Rotation based on horizontal drag // Rotation: 1 world pixel = ~0.3 degrees
item.data.angle = orig.angle + dx * 0.5; item.data.angle = orig.angle + dx * 0.3;
break; break;
} }
// Other handles: simple proportional for now
case 'tr': { case 'tr': {
const factor = 1 + (dx - dy) / 500; const factor = 1 + (dx - dy) / refSize;
item.data.sx = orig.sx * Math.max(0.1, factor); const clampedFactor = Math.max(0.05, factor);
item.data.sy = orig.sy * Math.max(0.1, factor); item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Top-right: left edge stays fixed
item.data.y = orig.y - (item.data.sy - orig.sy) * item.data.h;
break; break;
} }
case 'bl': { case 'bl': {
const factor = 1 + (-dx + dy) / 500; const factor = 1 + (-dx + dy) / refSize;
item.data.sx = orig.sx * Math.max(0.1, factor); const clampedFactor = Math.max(0.05, factor);
item.data.sy = orig.sy * Math.max(0.1, factor); item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Bottom-left: right edge stays fixed
item.data.x = orig.x - (item.data.sx - orig.sx) * item.data.w;
break; break;
} }
case 'tc': { case 'tc': {
const factor = 1 - dy / 500; const factor = 1 - dy / (bh || refSize);
item.data.sy = orig.sy * Math.max(0.1, factor); item.data.sy = orig.sy * Math.max(0.05, factor);
item.data.y = orig.y + dy; // Top-center: bottom edge stays fixed
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break; break;
} }
case 'ml': { case 'ml': {
const factor = 1 - dx / 500; const factor = 1 - dx / (bw || refSize);
item.data.sx = orig.sx * Math.max(0.1, factor); item.data.sx = orig.sx * Math.max(0.05, factor);
item.data.x = orig.x + dx; // Middle-left: right edge stays fixed
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break; break;
} }
} }