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 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} m²`;
|
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||||
}
|
}
|
||||||
@@ -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
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,
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user