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
This commit is contained in:
@@ -76,7 +76,7 @@
|
||||
<div id="room-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="info">Click a room to select it. Scroll to zoom, drag to orbit.</div>
|
||||
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
@@ -88,16 +88,35 @@
|
||||
</script>
|
||||
<script type="module">
|
||||
import { HouseRenderer } from './renderer.js';
|
||||
import { DesignState } from './state.js';
|
||||
import { InteractionManager } from './interaction.js';
|
||||
|
||||
const viewer = document.getElementById('viewer');
|
||||
const renderer = new HouseRenderer(viewer);
|
||||
const houseRenderer = new HouseRenderer(viewer);
|
||||
|
||||
let selectedRoom = null;
|
||||
let designState = null;
|
||||
let interaction = null;
|
||||
|
||||
renderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||
document.getElementById('house-name').textContent = house.name;
|
||||
await renderer.loadCatalog('../data/furniture-catalog.json');
|
||||
await renderer.loadDesign('../designs/sample-house-design.json');
|
||||
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
|
||||
const design = await houseRenderer.loadDesign('../designs/sample-house-design.json');
|
||||
|
||||
// Initialize state and interaction manager
|
||||
designState = new DesignState(design);
|
||||
interaction = new InteractionManager(houseRenderer, designState);
|
||||
|
||||
interaction.onChange((type, detail) => {
|
||||
if (type === 'select') {
|
||||
document.getElementById('info').textContent =
|
||||
`Selected: ${detail.itemName} — R to rotate, Delete to remove, Escape to deselect`;
|
||||
} else if (type === 'deselect') {
|
||||
document.getElementById('info').textContent =
|
||||
'Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.';
|
||||
}
|
||||
});
|
||||
|
||||
buildFloorButtons();
|
||||
buildRoomList();
|
||||
}).catch(err => {
|
||||
@@ -108,12 +127,12 @@
|
||||
function buildFloorButtons() {
|
||||
const container = document.getElementById('floor-buttons');
|
||||
container.innerHTML = '';
|
||||
for (const floor of renderer.getFloors()) {
|
||||
for (const floor of houseRenderer.getFloors()) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'floor-btn' + (floor.index === renderer.currentFloor ? ' active' : '');
|
||||
btn.className = 'floor-btn' + (floor.index === houseRenderer.currentFloor ? ' active' : '');
|
||||
btn.textContent = floor.name;
|
||||
btn.addEventListener('click', () => {
|
||||
renderer.showFloor(floor.index);
|
||||
houseRenderer.showFloor(floor.index);
|
||||
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
buildRoomList();
|
||||
@@ -126,7 +145,7 @@
|
||||
function buildRoomList() {
|
||||
const container = document.getElementById('room-list');
|
||||
container.innerHTML = '';
|
||||
for (const room of renderer.getRooms()) {
|
||||
for (const room of houseRenderer.getRooms()) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'room-item';
|
||||
item.dataset.roomId = room.id;
|
||||
@@ -138,11 +157,11 @@
|
||||
|
||||
function selectRoom(roomId) {
|
||||
selectedRoom = roomId;
|
||||
renderer.focusRoom(roomId);
|
||||
houseRenderer.focusRoom(roomId);
|
||||
document.querySelectorAll('.room-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.roomId === roomId);
|
||||
});
|
||||
const room = renderer.getRooms().find(r => r.id === roomId);
|
||||
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
||||
if (room) {
|
||||
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||
}
|
||||
@@ -151,11 +170,6 @@
|
||||
viewer.addEventListener('roomclick', (e) => {
|
||||
selectRoom(e.detail.roomId);
|
||||
});
|
||||
|
||||
viewer.addEventListener('furnitureclick', (e) => {
|
||||
const d = e.detail;
|
||||
document.getElementById('info').textContent = `${d.itemName} — in ${renderer.getRooms().find(r => r.id === d.roomId)?.name || d.roomId}`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
327
src/interaction.js
Normal file
327
src/interaction.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,7 @@ export class HouseRenderer {
|
||||
isFurniture: true,
|
||||
catalogId: placement.catalogId,
|
||||
roomId: roomDesign.roomId,
|
||||
furnitureIndex: i,
|
||||
itemName: catalogItem.name,
|
||||
wallMounted: !!placement.wallMounted
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user