Add drag-to-move furniture with grid snapping and room bounds
- Pointer down on selected furniture starts drag mode - OrbitControls disabled during drag - Floor plane (Y=0) raycasting for cursor projection - Drag offset preserves grab point (no cursor jump) - Grid snapping at 0.25m intervals (configurable) - Room bounds constraint keeps furniture inside walls - On pointer up, commits new position to DesignState - Wall-mounted items excluded from drag
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user