diff --git a/src/interaction.js b/src/interaction.js index 6c5a202..a91ff4d 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -23,6 +23,16 @@ export class InteractionManager { }); this._outlineGroup = null; + // Drag state + this._isDragging = false; + this._dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Y=0 floor plane + this._dragOffset = new THREE.Vector3(); + this._dragStartPos = null; // world position at drag start + this.snapEnabled = true; + this.snapSize = 0.25; // metres + this._roomBounds = new Map(); + this._furniturePadding = 0.05; // small buffer from walls + this._listeners = new Set(); // Bind event handlers (keep references for dispose) @@ -30,14 +40,24 @@ export class InteractionManager { this._onRoomClick = this._onRoomClick.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onFloorChange = this._onFloorChange.bind(this); + this._onPointerDown = this._onPointerDown.bind(this); + this._onPointerMove = this._onPointerMove.bind(this); + this._onPointerUp = this._onPointerUp.bind(this); + const canvas = this.renderer.renderer.domElement; this.renderer.container.addEventListener('furnitureclick', this._onFurnitureClick); this.renderer.container.addEventListener('roomclick', this._onRoomClick); this.renderer.container.addEventListener('floorchange', this._onFloorChange); + canvas.addEventListener('pointerdown', this._onPointerDown); + canvas.addEventListener('pointermove', this._onPointerMove); + canvas.addEventListener('pointerup', this._onPointerUp); 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)); + + // Build initial room bounds + this._buildRoomBounds(); } // ---- Mode system ---- @@ -152,6 +172,11 @@ export class InteractionManager { // ---- Event handlers ---- _onFurnitureClick(event) { + // Don't handle click if we just finished a drag + if (this._wasDragging) { + this._wasDragging = false; + return; + } const { mesh } = event.detail; if (mesh) { this.select(mesh); @@ -167,10 +192,124 @@ export class InteractionManager { _onFloorChange(_event) { // Floor switch invalidates selection (meshes are disposed) + this._isDragging = false; this.selectedObject = null; this.selectedRoomId = null; this.selectedIndex = -1; this._outlineGroup = null; + this._buildRoomBounds(); + } + + // ---- Drag handling ---- + + _getNDC(event) { + const rect = this.renderer.renderer.domElement.getBoundingClientRect(); + return new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + } + + _onPointerDown(event) { + if (event.button !== 0) return; // left click only + if (!this.selectedObject || this.selectedObject.userData.wallMounted) return; + + // Raycast to check if we're clicking the selected furniture + const ndc = this._getNDC(event); + this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera); + const hits = this.renderer.raycaster.intersectObject(this.selectedObject, true); + if (hits.length === 0) return; + + // Calculate drag offset so object doesn't jump to cursor + const intersection = new THREE.Vector3(); + this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection); + if (!intersection) return; + + this._dragOffset.copy(this.selectedObject.position).sub(intersection); + this._dragStartPos = this.selectedObject.position.clone(); + this._isDragging = true; + this._wasDragging = false; + this.mode = 'move'; + + // Disable orbit controls during drag + this.renderer.setControlsEnabled(false); + } + + _onPointerMove(event) { + if (!this._isDragging || !this.selectedObject) return; + + const ndc = this._getNDC(event); + this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera); + const intersection = new THREE.Vector3(); + this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection); + if (!intersection) return; + + // Apply drag offset + intersection.add(this._dragOffset); + + // Grid snapping (snap world position) + if (this.snapEnabled) { + intersection.x = Math.round(intersection.x / this.snapSize) * this.snapSize; + intersection.z = Math.round(intersection.z / this.snapSize) * this.snapSize; + } + + // Room bounds constraint + const bounds = this._roomBounds.get(this.selectedRoomId); + if (bounds) { + const pad = this._furniturePadding; + intersection.x = clamp(intersection.x, bounds.minX + pad, bounds.maxX - pad); + intersection.z = clamp(intersection.z, bounds.minZ + pad, bounds.maxZ - pad); + } + + // Keep original Y + intersection.y = this.selectedObject.position.y; + this.selectedObject.position.copy(intersection); + } + + _onPointerUp(_event) { + if (!this._isDragging) return; + this._isDragging = false; + this.renderer.setControlsEnabled(true); + + if (!this.selectedObject || !this._dragStartPos) { + this.mode = 'select'; + return; + } + + // Check if position actually changed + const moved = !this.selectedObject.position.equals(this._dragStartPos); + if (moved) { + this._wasDragging = true; // prevent click handler from re-selecting + + // Convert world position back to room-local coords for state + const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; + const room = floor?.rooms.find(r => r.id === this.selectedRoomId); + if (room) { + const localX = this.selectedObject.position.x - room.position.x; + const localZ = this.selectedObject.position.z - room.position.y; + this.state.moveFurniture(this.selectedRoomId, this.selectedIndex, { x: localX, z: localZ }); + } + } + + this.mode = 'select'; + this._dragStartPos = null; + } + + // ---- Room bounds ---- + + _buildRoomBounds() { + this._roomBounds.clear(); + if (!this.renderer.houseData) return; + const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; + if (!floor) return; + for (const room of floor.rooms) { + this._roomBounds.set(room.id, { + minX: room.position.x, + maxX: room.position.x + room.dimensions.width, + minZ: room.position.y, + maxZ: room.position.y + room.dimensions.length + }); + } } _onStateChange(type, _detail) { @@ -315,9 +454,13 @@ export class InteractionManager { // ---- Cleanup ---- dispose() { + const canvas = this.renderer.renderer.domElement; this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick); this.renderer.container.removeEventListener('roomclick', this._onRoomClick); this.renderer.container.removeEventListener('floorchange', this._onFloorChange); + canvas.removeEventListener('pointerdown', this._onPointerDown); + canvas.removeEventListener('pointermove', this._onPointerMove); + canvas.removeEventListener('pointerup', this._onPointerUp); window.removeEventListener('keydown', this._onKeyDown); this._unsubState(); this._removeOutline(); @@ -325,3 +468,7 @@ export class InteractionManager { this._listeners.clear(); } } + +function clamp(val, min, max) { + return Math.max(min, Math.min(max, val)); +}