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:
m
2026-02-07 12:23:27 +01:00
parent d0d9deb03a
commit c0368f9f01

View File

@@ -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));
}