# Interactive Features Design — Phase 2 **Task:** t-05700 **Date:** 2026-02-07 **Role:** Inventor ## Overview Four interactive feature areas to transform the viewer into an editor: 1. **Drag-and-drop furniture** — move, rotate, place from catalog 2. **Room editing** — resize rooms, edit wall openings 3. **Style themes** — switchable color/material palettes 4. **Export** — save designs, screenshot, share Design principle: **Enhance the existing vanilla JS + Three.js stack**. No framework rewrite. Each feature is an independent module that plugs into `HouseRenderer`. --- ## Architecture Strategy ### Module Structure ``` src/ renderer.js (existing — 3D core) index.html (existing — entry point) interaction.js (NEW — drag/drop, selection, gizmos) room-editor.js (NEW — room resize, wall editing) themes.js (NEW — style theme system) export.js (NEW — save/export functionality) ui-panels.js (NEW — sidebar panels, catalog browser, property inspector) ``` Each module exports a class that receives the `HouseRenderer` instance and extends it via composition (not inheritance). This keeps renderer.js stable while adding capabilities. ```javascript // Pattern for all modules: export class InteractionManager { constructor(renderer) { this.renderer = renderer; // Hook into renderer's scene, camera, controls } dispose() { /* cleanup */ } } ``` ### State Management Currently state lives in scattered instance variables. For interactive editing, we need a lightweight state layer: ```javascript // src/state.js — simple observable state export class DesignState { constructor(initialDesign) { this._state = structuredClone(initialDesign); this._listeners = new Set(); this._undoStack = []; this._redoStack = []; } // Read get design() { return this._state; } getRoomDesign(roomId) { ... } getFurniture(roomId, index) { ... } // Write (all mutations go through here) updateFurniture(roomId, index, changes) { this._pushUndo(); Object.assign(this._state.rooms[...].furniture[index], changes); this._notify('furniture-update', { roomId, index }); } moveFurniture(roomId, index, newPosition) { ... } rotateFurniture(roomId, index, degrees) { ... } addFurniture(roomId, catalogId, position, rotation) { ... } removeFurniture(roomId, index) { ... } // Undo/Redo undo() { ... } redo() { ... } // Observers onChange(listener) { this._listeners.add(listener); } _notify(type, detail) { for (const fn of this._listeners) fn(type, detail); } _pushUndo() { this._undoStack.push(structuredClone(this._state)); this._redoStack = []; } } ``` This is intentionally minimal — no Redux, no Zustand, just a class with undo/redo. --- ## Feature 1: Drag-and-Drop Furniture ### Interaction Modes The viewer operates in one of these modes: | Mode | Behavior | Activation | |------|----------|------------| | **View** | Current behavior — orbit, zoom, click rooms | Default | | **Select** | Click furniture to select, show properties | Toggle button | | **Move** | Drag selected furniture on floor plane | Select + drag | | **Rotate** | Rotate selected furniture around Y axis | R key or gizmo | | **Place** | Drag new item from catalog into scene | Catalog click | ### Selection System ```javascript class InteractionManager { constructor(renderer) { this.renderer = renderer; this.selectedObject = null; this.mode = 'view'; // view | select | move | rotate | place this.dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // floor plane this.dragOffset = new THREE.Vector3(); this._ghostMesh = null; // preview during placement // Event listeners this.renderer.renderer.domElement.addEventListener('pointerdown', e => this._onPointerDown(e)); this.renderer.renderer.domElement.addEventListener('pointermove', e => this._onPointerMove(e)); this.renderer.renderer.domElement.addEventListener('pointerup', e => this._onPointerUp(e)); window.addEventListener('keydown', e => this._onKeyDown(e)); } } ``` ### Furniture Selection When user clicks on a furniture object: 1. Raycast from camera through click point 2. Walk hit object up to find `userData.isFurniture` group 3. Apply selection highlight (outline or emissive tint) 4. Show property panel in sidebar 5. Enable move/rotate controls **Selection visual:** Outline effect using a slightly scaled-up wireframe clone with a distinct color (e.g., cyan). This avoids modifying original materials. ```javascript _selectFurniture(meshGroup) { this._clearSelection(); this.selectedObject = meshGroup; // Create outline by cloning with wireframe material const outline = meshGroup.clone(); outline.traverse(child => { if (child.isMesh) { child.material = this._outlineMaterial; // cyan wireframe, slightly larger scale child.scale.multiplyScalar(1.02); } }); outline.userData._isOutline = true; meshGroup.add(outline); } ``` ### Drag-to-Move When user drags a selected furniture piece: 1. On `pointerdown`: if hitting selected object, enter move mode 2. Disable OrbitControls (prevent camera rotation during drag) 3. Raycast pointer against floor plane (Y=0) each frame 4. Apply offset so object doesn't jump to cursor 5. Optionally snap to grid (0.1m or 0.25m increments) 6. Constrain within room bounds 7. On `pointerup`: commit new position to DesignState, re-enable OrbitControls ```javascript _onPointerMove(event) { if (this.mode !== 'move' || !this.selectedObject) return; const mouse = this._getNDC(event); this.renderer.raycaster.setFromCamera(mouse, this.renderer.camera); const intersection = new THREE.Vector3(); this.renderer.raycaster.ray.intersectPlane(this.dragPlane, intersection); if (intersection) { // Apply grid snapping if (this.snapEnabled) { intersection.x = Math.round(intersection.x / this.snapSize) * this.snapSize; intersection.z = Math.round(intersection.z / this.snapSize) * this.snapSize; } // Apply room bounds constraint const room = this._getContainingRoom(intersection); if (room) { intersection.x = clamp(intersection.x, room.bounds.minX + padding, room.bounds.maxX - padding); intersection.z = clamp(intersection.z, room.bounds.minZ + padding, room.bounds.maxZ - padding); } this.selectedObject.position.copy(intersection.sub(this.dragOffset)); } } ``` ### Rotation Two mechanisms: 1. **Quick rotate:** Press R key to rotate 90 degrees 2. **Gizmo:** Circular handle at base of selected furniture, drag to rotate freely Quick rotate is simplest to implement first: ```javascript _onKeyDown(event) { if (event.key === 'r' && this.selectedObject) { this.selectedObject.rotation.y -= Math.PI / 2; this.state.rotateFurniture(roomId, index, -90); } if (event.key === 'Delete' && this.selectedObject) { this.state.removeFurniture(roomId, index); this._removeFromScene(this.selectedObject); this._clearSelection(); } if (event.key === 'Escape') { this._clearSelection(); this.mode = 'view'; } } ``` ### Catalog Drag-to-Place Adding new furniture from the catalog sidebar: 1. User clicks a catalog item in sidebar 2. Create a ghost (semi-transparent) mesh from catalog definition 3. Ghost follows cursor, projected onto floor plane 4. Show green/red tint for valid/invalid placement 5. Click to place — add to DesignState and create real mesh 6. Right-click or Escape to cancel **Alternative (simpler):** Click catalog item, then click on room floor to place at that point. No drag-from-sidebar needed. This avoids the complexity of HTML-to-3D coordinate mapping. **Recommendation:** Start with click-to-place, add drag-from-sidebar later. ### Room Boundary Detection For constraining furniture to rooms, build bounding boxes from room data: ```javascript _buildRoomBounds() { const floor = this.renderer.houseData.floors[this.renderer.currentFloor]; this.roomBounds = new Map(); 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 }); } } ``` ### Collision Detection (Optional, Phase 2) Basic AABB overlap check to prevent furniture stacking: ```javascript _checkCollision(candidate, exclude) { const box1 = new THREE.Box3().setFromObject(candidate); for (const [key, mesh] of this.renderer.furnitureMeshes) { if (mesh === exclude) continue; const box2 = new THREE.Box3().setFromObject(mesh); if (box1.intersectsBox(box2)) return true; } return false; } ``` ### Implementation Priority | Step | What | Complexity | Depends On | |------|------|------------|------------| | 1 | Click-to-select furniture | Low | Existing raycaster | | 2 | Selection outline visual | Low | Step 1 | | 3 | Property panel in sidebar | Low | Step 1 | | 4 | Drag-to-move on floor plane | Medium | Step 1, OrbitControls toggle | | 5 | Grid snapping | Low | Step 4 | | 6 | Room bounds constraint | Low | Step 4 | | 7 | R key rotation | Low | Step 1 | | 8 | Delete key removal | Low | Step 1, DesignState | | 9 | Catalog click-to-place | Medium | DesignState | | 10 | Ghost preview during placement | Medium | Step 9 | | 11 | Undo/redo | Medium | DesignState | | 12 | Collision detection | Medium | Step 4 | --- ## Feature 2: Room Editing ### Scope Room editing is more complex than furniture interaction. Recommended phased approach: **Phase 2a (Do first):** Edit room properties only — name, flooring type, wall colors **Phase 2b (Later):** Resize rooms — drag walls to change dimensions **Phase 2c (Future):** Add/remove rooms, edit doors/windows ### Phase 2a: Room Property Editing When a room is selected, show editable properties in sidebar: ``` Room: Wohnzimmer ──────────────── Name: [Wohnzimmer ] Type: [Living Room ▾] Flooring: [Hardwood ▾] [Custom color: #b5894e] Size: 4.5m × 5.5m (24.8 m²) Ceiling: 2.6m Walls: North: [Interior ▾] — Door to Esszimmer South: [Exterior ▾] — Window (1.8m) East: [Interior ▾] — Door to Flur West: [Exterior ▾] — Patio door (2.0m) ``` Changes update both DesignState and the 3D scene in real-time. ### Phase 2b: Room Resize Allow dragging room edges to resize: 1. When room is selected, show drag handles on each wall midpoint 2. Dragging a handle moves that wall, changing room width or length 3. Adjacent rooms may need to adjust (constraint system) 4. Minimum room size: 1.5m × 1.5m 5. Grid snap: 0.25m increments **Wall drag handles:** Small sphere or cube meshes placed at wall midpoints, highlighted on hover. ```javascript _createResizeHandles(room) { const handles = []; const walls = ['north', 'south', 'east', 'west']; for (const wall of walls) { const handle = new THREE.Mesh( new THREE.SphereGeometry(0.1), new THREE.MeshStandardMaterial({ color: 0x4a90d9 }) ); handle.userData = { isHandle: true, wall, roomId: room.id }; // Position at wall midpoint handles.push(handle); } return handles; } ``` **Constraint challenge:** When room A's east wall moves, room B's west wall (if adjacent) should also move. This requires understanding room adjacency from door connections. The house data already has `connectsTo` fields on doors — use these to build an adjacency graph. ### Phase 2c: Door/Window Editing (Future) - Click wall to add/remove doors or windows - Drag door/window along wall to reposition - Change door/window types from property panel - This modifies `sample-house.json` structure **Recommendation:** Defer this to a future phase. It's architecturally complex (wall segmentation recalculation, connectivity validation) and the current house data is well-defined. ### Implementation Priority | Step | What | Complexity | |------|------|------------| | 1 | Room property panel (read-only) | Low | | 2 | Editable flooring type | Low | | 3 | Editable room name | Low | | 4 | Wall color customization | Medium | | 5 | Room resize handles | High | | 6 | Adjacent room constraint system | High | | 7 | Door/window editing | Very High | --- ## Feature 3: Style Themes ### Concept Predefined color/material palettes that restyle the entire house. Themes override the `COLORS` object and material properties. ### Theme Data Structure ```javascript // src/themes.js export const THEMES = { default: { name: 'Standard', colors: { wall: { exterior: 0xe8e0d4, interior: 0xf5f0eb }, floor: { tile: 0xc8beb0, hardwood: 0xb5894e }, ceiling: 0xfaf8f5, door: 0x8b6914, window: 0x87ceeb, windowFrame: 0xd0d0d0, grid: 0xcccccc, selected: 0x4a90d9 }, materials: { wallRoughness: 0.9, floorRoughness: 0.8, doorRoughness: 0.6 }, scene: { background: 0xf0f0f0, ambientIntensity: 0.6, directionalIntensity: 0.8 } }, modern: { name: 'Modern Minimal', colors: { wall: { exterior: 0xf5f5f5, interior: 0xffffff }, floor: { tile: 0xe0e0e0, hardwood: 0xc4a882 }, ceiling: 0xffffff, door: 0x333333, window: 0xa8d4f0, windowFrame: 0x666666, grid: 0xe0e0e0, selected: 0x2196f3 }, materials: { wallRoughness: 0.3, floorRoughness: 0.4, doorRoughness: 0.2 }, scene: { background: 0xfafafa, ambientIntensity: 0.7, directionalIntensity: 0.6 } }, warm: { name: 'Warm Rustic', colors: { wall: { exterior: 0xddd0b8, interior: 0xf0e8d8 }, floor: { tile: 0xb8a890, hardwood: 0x9b6b3a }, ceiling: 0xf5efe5, door: 0x6b4423, window: 0x8bc4e0, windowFrame: 0x8b7355, grid: 0xc8b8a0, selected: 0xd48b2c }, materials: { wallRoughness: 0.95, floorRoughness: 0.9, doorRoughness: 0.8 }, scene: { background: 0xf5efe5, ambientIntensity: 0.5, directionalIntensity: 0.9 } }, dark: { name: 'Dark Mode', colors: { wall: { exterior: 0x3a3a3a, interior: 0x4a4a4a }, floor: { tile: 0x2a2a2a, hardwood: 0x5a4030 }, ceiling: 0x333333, door: 0x5a4030, window: 0x4080b0, windowFrame: 0x555555, grid: 0x444444, selected: 0x64b5f6 }, materials: { wallRoughness: 0.7, floorRoughness: 0.6, doorRoughness: 0.5 }, scene: { background: 0x222222, ambientIntensity: 0.4, directionalIntensity: 1.0 } }, scandinavian: { name: 'Scandinavian', colors: { wall: { exterior: 0xf0ece4, interior: 0xfaf6f0 }, floor: { tile: 0xe8ddd0, hardwood: 0xd4b88c }, ceiling: 0xffffff, door: 0xc4a87a, window: 0xc0ddf0, windowFrame: 0xb0b0b0, grid: 0xd8d8d8, selected: 0x5b9bd5 }, materials: { wallRoughness: 0.5, floorRoughness: 0.6, doorRoughness: 0.4 }, scene: { background: 0xf8f6f2, ambientIntensity: 0.65, directionalIntensity: 0.7 } } }; ``` ### Theme Application ```javascript export class ThemeManager { constructor(renderer) { this.renderer = renderer; this.currentTheme = 'default'; } applyTheme(themeId) { const theme = THEMES[themeId]; if (!theme) return; this.currentTheme = themeId; // Update COLORS object (renderer reads from this) Object.assign(COLORS.wall, theme.colors.wall); Object.assign(COLORS.floor, theme.colors.floor); COLORS.ceiling = theme.colors.ceiling; COLORS.door = theme.colors.door; COLORS.window = theme.colors.window; COLORS.windowFrame = theme.colors.windowFrame; // Update scene this.renderer.scene.background.setHex(theme.scene.background); // Clear material cache (forces recreation with new colors) // Then re-render current floor this.renderer._clearFloor(); this.renderer.showFloor(this.renderer.currentFloor); } getThemes() { return Object.entries(THEMES).map(([id, t]) => ({ id, name: t.name })); } } ``` **Key insight:** The renderer already uses a `COLORS` constant and caches materials keyed by color. Changing `COLORS` and clearing the cache forces a full re-render with new colors. This is clean and requires minimal renderer changes — just make `COLORS` mutable (change `const` to `let`, or use an object that can be mutated). ### Renderer Change Required The `COLORS` constant in renderer.js needs to be accessible for mutation. Two options: **Option A (minimal):** Export `COLORS` and let ThemeManager mutate it directly. ```javascript export const COLORS = { ... }; // already an object, properties are mutable ``` **Option B (cleaner):** Add a `setColors(newColors)` method to HouseRenderer. Recommend Option A for simplicity — `COLORS` is already a mutable object. ### UI: Theme Selector Add a theme dropdown or button row to the sidebar: ```html