/** * DesignState — observable state with undo/redo for furniture design editing. * * All mutations go through methods that snapshot state for undo, then notify * listeners so the renderer (and future UI panels) can react. */ export class DesignState { constructor(initialDesign) { this._state = structuredClone(initialDesign); this._listeners = new Set(); this._undoStack = []; this._redoStack = []; this._maxUndo = 50; // Index rooms by id for fast lookup this._roomIndex = new Map(); this._rebuildIndex(); } // ---- Read ---- /** Full design object (read-only reference — do not mutate directly). */ get design() { return this._state; } /** Get room design entry by roomId, or undefined. */ getRoomDesign(roomId) { return this._roomIndex.get(roomId); } /** Get a single furniture placement by roomId and index. */ getFurniture(roomId, index) { const room = this._roomIndex.get(roomId); return room?.furniture[index]; } /** Get all furniture for a room. */ getRoomFurniture(roomId) { const room = this._roomIndex.get(roomId); return room?.furniture ?? []; } // ---- Write (all mutations go through these) ---- /** Generic update: merge `changes` into furniture at [roomId][index]. */ updateFurniture(roomId, index, changes) { const item = this._getFurnitureOrThrow(roomId, index); this._pushUndo(); Object.assign(item, changes); this._notify('furniture-update', { roomId, index }); } /** Move furniture to a new position {x, z} (and optionally y). */ moveFurniture(roomId, index, newPosition) { const item = this._getFurnitureOrThrow(roomId, index); this._pushUndo(); item.position = { ...item.position, ...newPosition }; this._notify('furniture-move', { roomId, index }); } /** Set furniture rotation in degrees. */ rotateFurniture(roomId, index, degrees) { const item = this._getFurnitureOrThrow(roomId, index); this._pushUndo(); item.rotation = degrees; this._notify('furniture-rotate', { roomId, index }); } /** Add a new furniture item to a room. Returns the new index. */ addFurniture(roomId, placement) { this._pushUndo(); let room = this._roomIndex.get(roomId); if (!room) { // Create room entry if it doesn't exist yet room = { roomId, furniture: [] }; this._state.rooms.push(room); this._roomIndex.set(roomId, room); } const index = room.furniture.length; room.furniture.push(structuredClone(placement)); this._notify('furniture-add', { roomId, index }); return index; } /** Remove furniture at [roomId][index]. Returns the removed item. */ removeFurniture(roomId, index) { const room = this._roomIndex.get(roomId); if (!room || index < 0 || index >= room.furniture.length) return null; this._pushUndo(); const [removed] = room.furniture.splice(index, 1); this._notify('furniture-remove', { roomId, index }); return removed; } /** Replace the entire design (e.g. when loading a saved file). */ loadDesign(newDesign) { this._pushUndo(); this._state = structuredClone(newDesign); this._rebuildIndex(); this._notify('design-load', {}); } // ---- Undo / Redo ---- get canUndo() { return this._undoStack.length > 0; } get canRedo() { return this._redoStack.length > 0; } undo() { if (!this.canUndo) return false; this._redoStack.push(structuredClone(this._state)); this._state = this._undoStack.pop(); this._rebuildIndex(); this._notify('undo', {}); return true; } redo() { if (!this.canRedo) return false; this._undoStack.push(structuredClone(this._state)); this._state = this._redoStack.pop(); this._rebuildIndex(); this._notify('redo', {}); return true; } // ---- Observers ---- /** * Register a change listener. Called with (type, detail) on every mutation. * Returns an unsubscribe function. * * Event types: * furniture-update, furniture-move, furniture-rotate, * furniture-add, furniture-remove, design-load, undo, redo */ onChange(listener) { this._listeners.add(listener); return () => this._listeners.delete(listener); } // ---- Serialization ---- /** Export current state as a plain JSON-serializable object. */ toJSON() { return structuredClone(this._state); } // ---- Internal ---- _notify(type, detail) { for (const fn of this._listeners) { try { fn(type, detail); } catch (err) { console.error('DesignState listener error:', err); } } } _pushUndo() { this._undoStack.push(structuredClone(this._state)); if (this._undoStack.length > this._maxUndo) { this._undoStack.shift(); } // Any new mutation clears the redo stack this._redoStack = []; } _rebuildIndex() { this._roomIndex.clear(); if (this._state?.rooms) { for (const room of this._state.rooms) { this._roomIndex.set(room.roomId, room); } } } _getFurnitureOrThrow(roomId, index) { const room = this._roomIndex.get(roomId); if (!room) throw new Error(`Room not found: ${roomId}`); const item = room.furniture[index]; if (!item) throw new Error(`Furniture not found: ${roomId}[${index}]`); return item; } }