/** * SceneManager — central object model replacing Fabric.js canvas object management. * * Owns the SceneItem map, handles diff-based scene loading, z-ordering, * and spring-animated add/remove operations. */ import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import { TextureManager, LODTier } from './TextureManager'; import { SpringManager, Spring, PRESETS } from './spring'; import type { SceneData, AnySceneObject, ImageObject, VideoObject, TextObject, GroupObject, } from './scene-format'; // --------------------------------------------------------------------------- // SceneItem // --------------------------------------------------------------------------- export interface SceneItem { id: string; type: 'image' | 'video' | 'text' | 'group'; displayObject: Container; data: AnySceneObject; } // --------------------------------------------------------------------------- // SceneManager // --------------------------------------------------------------------------- export class SceneManager { readonly items: Map = new Map(); readonly viewport: Viewport; readonly textures: TextureManager; readonly springs: SpringManager; private _onChange: (() => void) | null = null; private _zCounter: number = 0; constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) { this.viewport = viewport; this.textures = textures; this.springs = springs; } // -- onChange callback ---------------------------------------------------- set onChange(fn: (() => void) | null) { this._onChange = fn; } get onChange(): (() => void) | null { return this._onChange; } // -- Serialization ------------------------------------------------------- /** Convert all items to v2 scene format, sorted by z. */ serialize(): SceneData { const objects = Array.from(this.items.values()) .sort((a, b) => a.data.z - b.data.z) .map((item) => ({ ...item.data })); return { v: 2, bg: '', objects, }; } // -- Scene Loading (diff-based) ------------------------------------------ /** * Load a scene using diff-based reconciliation: * 1. Remove items not present in incoming data * 2. Update existing items with new properties * 3. Create new items * 4. Apply z-ordering */ async loadScene(scene: SceneData, animate = false): Promise { const incomingById = new Map(); for (const obj of scene.objects) { incomingById.set(obj.id, obj); } // 1. Remove items not in incoming set const toRemove: string[] = []; for (const id of this.items.keys()) { if (!incomingById.has(id)) { toRemove.push(id); } } for (const id of toRemove) { const item = this.items.get(id)!; item.displayObject.destroy({ children: true }); this.items.delete(id); } // 2 & 3. Update existing, create new const loadPromises: Promise[] = []; for (const data of scene.objects) { const existing = this.items.get(data.id); if (existing) { this._updateItem(existing, data); } else { loadPromises.push(this._createItem(data, animate)); } // Track highest z for nextZ() if (data.z >= this._zCounter) { this._zCounter = data.z + 1; } } await Promise.all(loadPromises); // 4. Apply z-ordering this._applyZOrder(); this._onChange?.(); } // -- Item Creation ------------------------------------------------------- /** Create a PixiJS display object from scene data and add it to the viewport. */ async _createItem(data: AnySceneObject, animate: boolean): Promise { let displayObject: Container; switch (data.type) { case 'image': { const imgData = data as ImageObject; const sprite = new Sprite(Texture.EMPTY); sprite.width = imgData.w; sprite.height = imgData.h; // Load texture asynchronously at appropriate LOD const zoom = this.viewport.scale.x; const tier = this.textures.tierForZoom(zoom); this.textures.load(imgData.asset, tier).then((tex) => { if (!sprite.destroyed) { sprite.texture = tex; sprite.width = imgData.w; sprite.height = imgData.h; } }); displayObject = sprite; break; } case 'video': { const vidData = data as VideoObject; const gfx = new Graphics(); gfx.rect(0, 0, vidData.w, vidData.h); gfx.fill(0x333333); displayObject = gfx; break; } case 'text': { const txtData = data as TextObject; const style = new TextStyle({ fontSize: txtData.fontSize, fill: txtData.fill, fontFamily: txtData.fontFamily, }); displayObject = new Text({ text: txtData.text, style }); break; } case 'group': { displayObject = new Container(); break; } default: displayObject = new Container(); break; } // Common properties displayObject.position.set(data.x, data.y); displayObject.scale.set(data.sx, data.sy); displayObject.angle = data.angle; displayObject.alpha = data.opacity; displayObject.visible = data.visible; displayObject.eventMode = data.locked ? 'none' : 'static'; displayObject.label = data.id; displayObject.cursor = 'pointer'; this.viewport.addChild(displayObject); const item: SceneItem = { id: data.id, type: data.type, displayObject, data: { ...data }, }; this.items.set(data.id, item); // Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in if (animate) { displayObject.scale.set(0, 0); displayObject.alpha = 0; const scaleSpring = new Spring(0, 1.05, PRESETS.bounce); scaleSpring.onUpdate = (v) => { if (!displayObject.destroyed) { displayObject.scale.set(v * data.sx, v * data.sy); } }; scaleSpring.onComplete = () => { // Second spring: 1.05 → 1.0 const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy); settleSpring.onUpdate = (v) => { if (!displayObject.destroyed) { displayObject.scale.set(v * data.sx, v * data.sy); } }; this.springs.add(settleSpring); }; this.springs.add(scaleSpring); const alphaSpring = new Spring(0, data.opacity, PRESETS.gentle); alphaSpring.onUpdate = (v) => { if (!displayObject.destroyed) { displayObject.alpha = v; } }; this.springs.add(alphaSpring); } } // -- Item Update --------------------------------------------------------- /** Update an existing item's display object properties from new data. */ _updateItem(item: SceneItem, data: AnySceneObject): void { const obj = item.displayObject; obj.position.set(data.x, data.y); obj.scale.set(data.sx, data.sy); obj.angle = data.angle; obj.alpha = data.opacity; obj.visible = data.visible; obj.eventMode = data.locked ? 'none' : 'static'; // Update stored data item.data = { ...data }; item.type = data.type; } // -- Z-Ordering ---------------------------------------------------------- /** Sort items by z and reorder children in the viewport. */ _applyZOrder(): void { const sorted = Array.from(this.items.values()).sort( (a, b) => a.data.z - b.data.z, ); for (let i = 0; i < sorted.length; i++) { const child = sorted[i].displayObject; if (child.parent === this.viewport) { this.viewport.setChildIndex(child, i); } } } // -- Lookup Helpers ------------------------------------------------------ getById(id: string): SceneItem | undefined { return this.items.get(id); } getAllItems(): SceneItem[] { return Array.from(this.items.values()); } // -- Remove Item --------------------------------------------------------- /** Remove an item, optionally animating scale→0.8 + fade out before destroy. */ removeItem(id: string, animate = true): void { const item = this.items.get(id); if (!item) return; if (!animate) { item.displayObject.destroy({ children: true }); this.items.delete(id); this._onChange?.(); return; } const obj = item.displayObject; // Remove from map immediately to prevent double-remove this.items.delete(id); const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy); scaleSpring.onUpdate = (v) => { if (!obj.destroyed) { obj.scale.set(v * (item.data.sx), v * (item.data.sy)); } }; this.springs.add(scaleSpring); const alphaSpring = new Spring(obj.alpha, 0, PRESETS.snappy); alphaSpring.onUpdate = (v) => { if (!obj.destroyed) { obj.alpha = Math.max(0, v); } }; alphaSpring.onComplete = () => { if (!obj.destroyed) { obj.destroy({ children: true }); } }; this.springs.add(alphaSpring); this._onChange?.(); } // -- Z Counter ----------------------------------------------------------- /** Increment and return the next z value. */ nextZ(): number { return this._zCounter++; } // -- Convenience: Add Image From Upload ---------------------------------- /** Create an ImageObject from an upload and add it to the scene with animation. */ addImageFromUpload( assetKey: string, w: number, h: number, x: number, y: number, ): SceneItem { const data: ImageObject = { id: crypto.randomUUID(), type: 'image', x, y, w, h, sx: 1, sy: 1, angle: 0, z: this.nextZ(), opacity: 1, locked: false, name: '', visible: true, asset: assetKey, filters: [], }; // Fire-and-forget the async creation (animation handles visual feedback) this._createItem(data, true); this._applyZOrder(); this._onChange?.(); return this.items.get(data.id)!; } }