diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..a5d8852 --- /dev/null +++ b/src/state.js @@ -0,0 +1,191 @@ +/** + * 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; + } +}