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:
m
2026-02-07 12:22:06 +01:00
parent 36bc0aedd7
commit d0d9deb03a
3 changed files with 358 additions and 16 deletions

View File

@@ -76,7 +76,7 @@
<div id="room-list"></div> <div id="room-list"></div>
</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"> <script type="importmap">
{ {
@@ -88,16 +88,35 @@
</script> </script>
<script type="module"> <script type="module">
import { HouseRenderer } from './renderer.js'; import { HouseRenderer } from './renderer.js';
import { DesignState } from './state.js';
import { InteractionManager } from './interaction.js';
const viewer = document.getElementById('viewer'); const viewer = document.getElementById('viewer');
const renderer = new HouseRenderer(viewer); const houseRenderer = new HouseRenderer(viewer);
let selectedRoom = null; 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; document.getElementById('house-name').textContent = house.name;
await renderer.loadCatalog('../data/furniture-catalog.json'); await houseRenderer.loadCatalog('../data/furniture-catalog.json');
await renderer.loadDesign('../designs/sample-house-design.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(); buildFloorButtons();
buildRoomList(); buildRoomList();
}).catch(err => { }).catch(err => {
@@ -108,12 +127,12 @@
function buildFloorButtons() { function buildFloorButtons() {
const container = document.getElementById('floor-buttons'); const container = document.getElementById('floor-buttons');
container.innerHTML = ''; container.innerHTML = '';
for (const floor of renderer.getFloors()) { for (const floor of houseRenderer.getFloors()) {
const btn = document.createElement('button'); 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.textContent = floor.name;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
renderer.showFloor(floor.index); houseRenderer.showFloor(floor.index);
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
buildRoomList(); buildRoomList();
@@ -126,7 +145,7 @@
function buildRoomList() { function buildRoomList() {
const container = document.getElementById('room-list'); const container = document.getElementById('room-list');
container.innerHTML = ''; container.innerHTML = '';
for (const room of renderer.getRooms()) { for (const room of houseRenderer.getRooms()) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'room-item'; item.className = 'room-item';
item.dataset.roomId = room.id; item.dataset.roomId = room.id;
@@ -138,11 +157,11 @@
function selectRoom(roomId) { function selectRoom(roomId) {
selectedRoom = roomId; selectedRoom = roomId;
renderer.focusRoom(roomId); houseRenderer.focusRoom(roomId);
document.querySelectorAll('.room-item').forEach(el => { document.querySelectorAll('.room-item').forEach(el => {
el.classList.toggle('active', el.dataset.roomId === roomId); 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) { if (room) {
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area}`; document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area}`;
} }
@@ -151,11 +170,6 @@
viewer.addEventListener('roomclick', (e) => { viewer.addEventListener('roomclick', (e) => {
selectRoom(e.detail.roomId); 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> </script>
</body> </body>
</html> </html>

327
src/interaction.js Normal file
View 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();
}
}

View File

@@ -575,6 +575,7 @@ export class HouseRenderer {
isFurniture: true, isFurniture: true,
catalogId: placement.catalogId, catalogId: placement.catalogId,
roomId: roomDesign.roomId, roomId: roomDesign.roomId,
furnitureIndex: i,
itemName: catalogItem.name, itemName: catalogItem.name,
wallMounted: !!placement.wallMounted wallMounted: !!placement.wallMounted
}; };