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:
@@ -69,6 +69,7 @@ export class SelectionManager {
|
||||
|
||||
// Transform box
|
||||
this.transformBox = new TransformBox();
|
||||
this.transformBox.setViewport(viewport);
|
||||
this._overlay.addChild(this.transformBox);
|
||||
|
||||
// Bind events on viewport
|
||||
@@ -131,7 +132,7 @@ export class SelectionManager {
|
||||
|
||||
// -- 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 {
|
||||
const all = this._scene.getAllItems();
|
||||
// Sort by z descending (topmost first)
|
||||
@@ -141,13 +142,13 @@ export class SelectionManager {
|
||||
if (item.data.locked) continue;
|
||||
if (!item.data.visible) continue;
|
||||
|
||||
const bounds = item.displayObject.getBounds();
|
||||
if (
|
||||
bounds.x <= wx &&
|
||||
wx <= bounds.x + bounds.width &&
|
||||
bounds.y <= wy &&
|
||||
wy <= bounds.y + bounds.height
|
||||
) {
|
||||
// Use item.data (world-space) instead of getBounds() (screen-space)
|
||||
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);
|
||||
|
||||
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -344,14 +345,18 @@ export class SelectionManager {
|
||||
for (const item of this._scene.getAllItems()) {
|
||||
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 =
|
||||
rx < bounds.x + bounds.width &&
|
||||
rx + rw > bounds.x &&
|
||||
ry < bounds.y + bounds.height &&
|
||||
ry + rh > bounds.y;
|
||||
rx < ix + iw &&
|
||||
rx + rw > ix &&
|
||||
ry < iy + ih &&
|
||||
ry + rh > iy;
|
||||
|
||||
if (intersects) {
|
||||
this.selectedIds.add(item.id);
|
||||
|
||||
@@ -21,18 +21,39 @@ export class TextureManager {
|
||||
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. */
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a texture for the given asset and LOD tier.
|
||||
* Returns a cached texture if available, otherwise fetches it,
|
||||
* estimates GPU memory, and evicts LRU entries if over budget.
|
||||
* For legacy assets (pre-migration), loads the original file directly —
|
||||
* the GPU handles downscaling naturally.
|
||||
* For new assets, loads the appropriate LOD tier with fallback.
|
||||
*/
|
||||
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);
|
||||
if (existing) {
|
||||
@@ -40,8 +61,14 @@ export class TextureManager {
|
||||
return existing.texture;
|
||||
}
|
||||
|
||||
const url = this.urlForAsset(assetKey, tier);
|
||||
const texture: Texture = await Assets.load(url);
|
||||
const url = this.urlForAsset(assetKey, effectiveTier);
|
||||
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 h = texture.source.height ?? 256;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +59,7 @@ export class TransformBox extends Container {
|
||||
private _items: SceneItem[] = [];
|
||||
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
||||
private _drag: DragState | null = null;
|
||||
private _viewport: Viewport | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -92,6 +94,11 @@ export class TransformBox extends Container {
|
||||
|
||||
// -- 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(items: SceneItem[]): void {
|
||||
this._items = items;
|
||||
@@ -101,18 +108,22 @@ export class TransformBox extends Container {
|
||||
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 minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const item of items) {
|
||||
const b = item.displayObject.getBounds();
|
||||
if (b.x < minX) minX = b.x;
|
||||
if (b.y < minY) minY = b.y;
|
||||
if (b.x + b.width > maxX) maxX = b.x + b.width;
|
||||
if (b.y + b.height > maxY) maxY = b.y + b.height;
|
||||
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);
|
||||
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 };
|
||||
@@ -200,76 +211,86 @@ export class TransformBox extends Container {
|
||||
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||
if (!this._drag) return;
|
||||
|
||||
const dx = e.global.x - this._drag.startX;
|
||||
const dy = e.global.y - this._drag.startY;
|
||||
// Convert screen-space deltas to world-space by dividing by viewport zoom
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
const orig = origTransforms.get(item.id);
|
||||
if (!orig) continue;
|
||||
|
||||
switch (handleId) {
|
||||
case 'br': {
|
||||
// Proportional scale
|
||||
const factor = 1 + (dx + dy) / 500;
|
||||
const clampedFactor = Math.max(0.1, factor);
|
||||
// Proportional scale — drag distance relative to object size
|
||||
const factor = 1 + (dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
break;
|
||||
}
|
||||
case 'mr': {
|
||||
// Horizontal scale only
|
||||
const factor = 1 + dx / 500;
|
||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
||||
const factor = 1 + dx / (bw || refSize);
|
||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||
break;
|
||||
}
|
||||
case 'bc': {
|
||||
// Vertical scale only
|
||||
const factor = 1 + dy / 500;
|
||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
||||
const factor = 1 + dy / (bh || refSize);
|
||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||
break;
|
||||
}
|
||||
case 'tl': {
|
||||
// Proportional scale + reposition (opposite corner stays fixed)
|
||||
const factor = 1 - (dx + dy) / 500;
|
||||
const clampedFactor = Math.max(0.1, factor);
|
||||
// Proportional scale + reposition (bottom-right stays fixed)
|
||||
const factor = 1 - (dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
// Shift position so bottom-right stays fixed
|
||||
const dw = (item.data.sx - orig.sx) * (item.data.w ?? 0);
|
||||
const dh = (item.data.sy - orig.sy) * (item.data.h ?? 0);
|
||||
const dw = (item.data.sx - orig.sx) * item.data.w;
|
||||
const dh = (item.data.sy - orig.sy) * item.data.h;
|
||||
item.data.x = orig.x - dw;
|
||||
item.data.y = orig.y - dh;
|
||||
break;
|
||||
}
|
||||
case 'rot': {
|
||||
// Rotation based on horizontal drag
|
||||
item.data.angle = orig.angle + dx * 0.5;
|
||||
// Rotation: 1 world pixel = ~0.3 degrees
|
||||
item.data.angle = orig.angle + dx * 0.3;
|
||||
break;
|
||||
}
|
||||
// Other handles: simple proportional for now
|
||||
case 'tr': {
|
||||
const factor = 1 + (dx - dy) / 500;
|
||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
||||
const factor = 1 + (dx - dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, 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;
|
||||
}
|
||||
case 'bl': {
|
||||
const factor = 1 + (-dx + dy) / 500;
|
||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
||||
const factor = 1 + (-dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, 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;
|
||||
}
|
||||
case 'tc': {
|
||||
const factor = 1 - dy / 500;
|
||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
||||
item.data.y = orig.y + dy;
|
||||
const factor = 1 - dy / (bh || refSize);
|
||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||
// Top-center: bottom edge stays fixed
|
||||
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||
break;
|
||||
}
|
||||
case 'ml': {
|
||||
const factor = 1 - dx / 500;
|
||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
||||
item.data.x = orig.x + dx;
|
||||
const factor = 1 - dx / (bw || refSize);
|
||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||
// Middle-left: right edge stays fixed
|
||||
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user