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;
|
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();
|
this._listeners = new Set();
|
||||||
|
|
||||||
// Bind event handlers (keep references for dispose)
|
// Bind event handlers (keep references for dispose)
|
||||||
@@ -30,14 +40,24 @@ export class InteractionManager {
|
|||||||
this._onRoomClick = this._onRoomClick.bind(this);
|
this._onRoomClick = this._onRoomClick.bind(this);
|
||||||
this._onKeyDown = this._onKeyDown.bind(this);
|
this._onKeyDown = this._onKeyDown.bind(this);
|
||||||
this._onFloorChange = this._onFloorChange.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('furnitureclick', this._onFurnitureClick);
|
||||||
this.renderer.container.addEventListener('roomclick', this._onRoomClick);
|
this.renderer.container.addEventListener('roomclick', this._onRoomClick);
|
||||||
this.renderer.container.addEventListener('floorchange', this._onFloorChange);
|
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);
|
window.addEventListener('keydown', this._onKeyDown);
|
||||||
|
|
||||||
// Listen to DesignState changes to keep scene in sync
|
// Listen to DesignState changes to keep scene in sync
|
||||||
this._unsubState = this.state.onChange((type, detail) => this._onStateChange(type, detail));
|
this._unsubState = this.state.onChange((type, detail) => this._onStateChange(type, detail));
|
||||||
|
|
||||||
|
// Build initial room bounds
|
||||||
|
this._buildRoomBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mode system ----
|
// ---- Mode system ----
|
||||||
@@ -152,6 +172,11 @@ export class InteractionManager {
|
|||||||
// ---- Event handlers ----
|
// ---- Event handlers ----
|
||||||
|
|
||||||
_onFurnitureClick(event) {
|
_onFurnitureClick(event) {
|
||||||
|
// Don't handle click if we just finished a drag
|
||||||
|
if (this._wasDragging) {
|
||||||
|
this._wasDragging = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { mesh } = event.detail;
|
const { mesh } = event.detail;
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
this.select(mesh);
|
this.select(mesh);
|
||||||
@@ -167,10 +192,124 @@ export class InteractionManager {
|
|||||||
|
|
||||||
_onFloorChange(_event) {
|
_onFloorChange(_event) {
|
||||||
// Floor switch invalidates selection (meshes are disposed)
|
// Floor switch invalidates selection (meshes are disposed)
|
||||||
|
this._isDragging = false;
|
||||||
this.selectedObject = null;
|
this.selectedObject = null;
|
||||||
this.selectedRoomId = null;
|
this.selectedRoomId = null;
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this._outlineGroup = null;
|
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) {
|
_onStateChange(type, _detail) {
|
||||||
@@ -315,9 +454,13 @@ export class InteractionManager {
|
|||||||
// ---- Cleanup ----
|
// ---- Cleanup ----
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
const canvas = this.renderer.renderer.domElement;
|
||||||
this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick);
|
this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick);
|
||||||
this.renderer.container.removeEventListener('roomclick', this._onRoomClick);
|
this.renderer.container.removeEventListener('roomclick', this._onRoomClick);
|
||||||
this.renderer.container.removeEventListener('floorchange', this._onFloorChange);
|
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);
|
window.removeEventListener('keydown', this._onKeyDown);
|
||||||
this._unsubState();
|
this._unsubState();
|
||||||
this._removeOutline();
|
this._removeOutline();
|
||||||
@@ -325,3 +468,7 @@ export class InteractionManager {
|
|||||||
this._listeners.clear();
|
this._listeners.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(val, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, val));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user