From d0d9deb03a9369a4a88434d04bfe358734cdf3c0 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 7 Feb 2026 12:22:06 +0100 Subject: [PATCH] Add InteractionManager with mode system, selection outline, keyboard shortcuts - Mode system: view | select | move | rotate | place - Click furniture to select with cyan wireframe outline - Keyboard: R/Shift+R rotate, Delete remove, Escape deselect, Ctrl+Z undo, Ctrl+Shift+Z/Ctrl+Y redo - Listens to DesignState changes to sync scene on undo/redo - Wired into index.html with DesignState integration - Added furnitureIndex to mesh userData for state lookups --- src/index.html | 46 ++++--- src/interaction.js | 327 +++++++++++++++++++++++++++++++++++++++++++++ src/renderer.js | 1 + 3 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 src/interaction.js diff --git a/src/index.html b/src/index.html index 70c9cbe..961617d 100644 --- a/src/index.html +++ b/src/index.html @@ -76,7 +76,7 @@
-
Click a room to select it. Scroll to zoom, drag to orbit.
+
Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.
diff --git a/src/interaction.js b/src/interaction.js new file mode 100644 index 0000000..6c5a202 --- /dev/null +++ b/src/interaction.js @@ -0,0 +1,327 @@ +import * as THREE from 'three'; + +/** + * InteractionManager — mode system, selection, keyboard shortcuts. + * + * Modes: view (default) | select | move | rotate | place + * Receives HouseRenderer + DesignState, hooks into renderer events. + */ +export class InteractionManager { + constructor(renderer, state) { + this.renderer = renderer; + this.state = state; + this.mode = 'view'; + this.selectedObject = null; + this.selectedRoomId = null; + this.selectedIndex = -1; + + this._outlineMaterial = new THREE.MeshBasicMaterial({ + color: 0x00e5ff, + wireframe: true, + transparent: true, + opacity: 0.6 + }); + this._outlineGroup = null; + + this._listeners = new Set(); + + // Bind event handlers (keep references for dispose) + this._onFurnitureClick = this._onFurnitureClick.bind(this); + this._onRoomClick = this._onRoomClick.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onFloorChange = this._onFloorChange.bind(this); + + this.renderer.container.addEventListener('furnitureclick', this._onFurnitureClick); + this.renderer.container.addEventListener('roomclick', this._onRoomClick); + this.renderer.container.addEventListener('floorchange', this._onFloorChange); + window.addEventListener('keydown', this._onKeyDown); + + // Listen to DesignState changes to keep scene in sync + this._unsubState = this.state.onChange((type, detail) => this._onStateChange(type, detail)); + } + + // ---- Mode system ---- + + setMode(newMode) { + if (this.mode === newMode) return; + const oldMode = this.mode; + this.mode = newMode; + + // Leaving select/move/rotate means clear selection + if (newMode === 'view') { + this.clearSelection(); + } + + this._emit('modechange', { oldMode, newMode }); + } + + // ---- Selection ---- + + select(meshGroup) { + if (this.selectedObject === meshGroup) return; + this.clearSelection(); + + this.selectedObject = meshGroup; + this.selectedRoomId = meshGroup.userData.roomId; + this.selectedIndex = meshGroup.userData.furnitureIndex; + + this._addOutline(meshGroup); + + if (this.mode === 'view') { + this.mode = 'select'; + } + + this._emit('select', { + roomId: this.selectedRoomId, + index: this.selectedIndex, + catalogId: meshGroup.userData.catalogId, + itemName: meshGroup.userData.itemName, + mesh: meshGroup + }); + } + + clearSelection() { + if (!this.selectedObject) return; + this._removeOutline(); + const prev = { + roomId: this.selectedRoomId, + index: this.selectedIndex + }; + this.selectedObject = null; + this.selectedRoomId = null; + this.selectedIndex = -1; + this._emit('deselect', prev); + } + + // ---- Keyboard shortcuts ---- + + _onKeyDown(event) { + // Don't intercept when typing in an input + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; + + // Ctrl+Z / Cmd+Z — undo + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key === 'z') { + event.preventDefault(); + this.state.undo(); + return; + } + + // Ctrl+Shift+Z / Cmd+Shift+Z — redo + if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'Z') { + event.preventDefault(); + this.state.redo(); + return; + } + + // Ctrl+Y — redo (alternative) + if ((event.ctrlKey || event.metaKey) && event.key === 'y') { + event.preventDefault(); + this.state.redo(); + return; + } + + // Escape — deselect / return to view mode + if (event.key === 'Escape') { + this.clearSelection(); + this.setMode('view'); + return; + } + + // Following shortcuts require a selection + if (!this.selectedObject) return; + + // Delete / Backspace — remove selected furniture + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + this._deleteSelected(); + return; + } + + // R — rotate 90° clockwise + if (event.key === 'r' || event.key === 'R') { + const delta = event.shiftKey ? 90 : -90; + const item = this.state.getFurniture(this.selectedRoomId, this.selectedIndex); + if (item) { + const newRotation = ((item.rotation || 0) + delta + 360) % 360; + this.state.rotateFurniture(this.selectedRoomId, this.selectedIndex, newRotation); + } + return; + } + } + + // ---- Event handlers ---- + + _onFurnitureClick(event) { + const { mesh } = event.detail; + if (mesh) { + this.select(mesh); + } + } + + _onRoomClick(_event) { + // Clicking a room (not furniture) clears furniture selection + if (this.selectedObject) { + this.clearSelection(); + } + } + + _onFloorChange(_event) { + // Floor switch invalidates selection (meshes are disposed) + this.selectedObject = null; + this.selectedRoomId = null; + this.selectedIndex = -1; + this._outlineGroup = null; + } + + _onStateChange(type, _detail) { + // On undo/redo/load, re-render the floor to reflect new state + if (type === 'undo' || type === 'redo' || type === 'design-load') { + this.renderer.designData = this.state.design; + const wasSelected = this.selectedRoomId; + const wasIndex = this.selectedIndex; + + this._outlineGroup = null; + this.selectedObject = null; + + this.renderer._clearFloor(); + const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; + if (floor) { + for (const room of floor.rooms) { + this.renderer._renderRoom(room, floor.ceilingHeight); + } + } + this.renderer._placeFurnitureForFloor(); + + // Try to re-select the same item after re-render + if (wasSelected !== null && wasIndex >= 0) { + const mesh = this._findFurnitureMesh(wasSelected, wasIndex); + if (mesh) { + this.select(mesh); + } else { + this.selectedRoomId = null; + this.selectedIndex = -1; + this._emit('deselect', { roomId: wasSelected, index: wasIndex }); + } + } + } + + // On individual mutations, update the mesh in place + if (type === 'furniture-move' || type === 'furniture-rotate') { + this._syncMeshFromState(_detail.roomId, _detail.index); + } + + if (type === 'furniture-remove') { + // Mesh was already removed by state; re-render the floor + this.clearSelection(); + this.renderer.designData = this.state.design; + this.renderer._clearFloor(); + const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; + if (floor) { + for (const room of floor.rooms) { + this.renderer._renderRoom(room, floor.ceilingHeight); + } + } + this.renderer._placeFurnitureForFloor(); + } + } + + // ---- Outline ---- + + _addOutline(meshGroup) { + this._removeOutline(); + const outline = new THREE.Group(); + outline.userData._isOutline = true; + + meshGroup.traverse(child => { + if (child.isMesh && child.geometry) { + const clone = new THREE.Mesh(child.geometry, this._outlineMaterial); + // Copy the child's local transform relative to the group + clone.position.copy(child.position); + clone.rotation.copy(child.rotation); + clone.scale.copy(child.scale).multiplyScalar(1.04); + outline.add(clone); + } + }); + + meshGroup.add(outline); + this._outlineGroup = outline; + } + + _removeOutline() { + if (this._outlineGroup && this._outlineGroup.parent) { + this._outlineGroup.parent.remove(this._outlineGroup); + } + this._outlineGroup = null; + } + + // ---- Helpers ---- + + _deleteSelected() { + if (!this.selectedObject || this.selectedRoomId === null || this.selectedIndex < 0) return; + const roomId = this.selectedRoomId; + const index = this.selectedIndex; + this.clearSelection(); + this.state.removeFurniture(roomId, index); + } + + _findFurnitureMesh(roomId, index) { + for (const mesh of this.renderer.furnitureMeshes.values()) { + if (mesh.userData.roomId === roomId && mesh.userData.furnitureIndex === index) { + return mesh; + } + } + return null; + } + + _syncMeshFromState(roomId, index) { + const item = this.state.getFurniture(roomId, index); + const mesh = this._findFurnitureMesh(roomId, index); + if (!item || !mesh) return; + + // Get room position offset + const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; + const room = floor?.rooms.find(r => r.id === roomId); + if (!room) return; + + const rx = room.position.x + item.position.x; + const rz = room.position.y + item.position.z; + const ry = item.wallMounted ? (item.position.y ?? 0) : 0; + mesh.position.set(rx, ry, rz); + mesh.rotation.y = -(item.rotation * Math.PI) / 180; + } + + // ---- Observer pattern ---- + + /** + * Listen for interaction events. + * Types: select, deselect, modechange + * Returns unsubscribe function. + */ + onChange(listener) { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + + _emit(type, detail) { + for (const fn of this._listeners) { + try { + fn(type, detail); + } catch (err) { + console.error('InteractionManager listener error:', err); + } + } + } + + // ---- Cleanup ---- + + dispose() { + this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick); + this.renderer.container.removeEventListener('roomclick', this._onRoomClick); + this.renderer.container.removeEventListener('floorchange', this._onFloorChange); + window.removeEventListener('keydown', this._onKeyDown); + this._unsubState(); + this._removeOutline(); + this._outlineMaterial.dispose(); + this._listeners.clear(); + } +} diff --git a/src/renderer.js b/src/renderer.js index 2876f09..e68089d 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -575,6 +575,7 @@ export class HouseRenderer { isFurniture: true, catalogId: placement.catalogId, roomId: roomDesign.roomId, + furnitureIndex: i, itemName: catalogItem.name, wallMounted: !!placement.wallMounted };