- RESEARCH.md: Tech stack analysis (Three.js, SVG, hybrid approaches) - DESIGN-interactive-features.md: Phase 2 design with 5 sprints, 14 tasks
30 KiB
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:
- Drag-and-drop furniture — move, rotate, place from catalog
- Room editing — resize rooms, edit wall openings
- Style themes — switchable color/material palettes
- 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.
// 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:
// 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
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:
- Raycast from camera through click point
- Walk hit object up to find
userData.isFurnituregroup - Apply selection highlight (outline or emissive tint)
- Show property panel in sidebar
- 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.
_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:
- On
pointerdown: if hitting selected object, enter move mode - Disable OrbitControls (prevent camera rotation during drag)
- Raycast pointer against floor plane (Y=0) each frame
- Apply offset so object doesn't jump to cursor
- Optionally snap to grid (0.1m or 0.25m increments)
- Constrain within room bounds
- On
pointerup: commit new position to DesignState, re-enable OrbitControls
_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:
- Quick rotate: Press R key to rotate 90 degrees
- Gizmo: Circular handle at base of selected furniture, drag to rotate freely
Quick rotate is simplest to implement first:
_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:
- User clicks a catalog item in sidebar
- Create a ghost (semi-transparent) mesh from catalog definition
- Ghost follows cursor, projected onto floor plane
- Show green/red tint for valid/invalid placement
- Click to place — add to DesignState and create real mesh
- 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:
_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:
_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:
- When room is selected, show drag handles on each wall midpoint
- Dragging a handle moves that wall, changing room width or length
- Adjacent rooms may need to adjust (constraint system)
- Minimum room size: 1.5m × 1.5m
- Grid snap: 0.25m increments
Wall drag handles: Small sphere or cube meshes placed at wall midpoints, highlighted on hover.
_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.jsonstructure
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
// 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
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.
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:
<h3>Theme</h3>
<div id="theme-buttons">
<!-- Generated: one button per theme -->
<button class="theme-btn active" data-theme="default">
<span class="theme-swatch" style="background: #e8e0d4"></span>
Standard
</button>
...
</div>
Each theme button shows a small color swatch preview.
Per-Room Color Override (Stretch)
Allow individual rooms to override theme colors:
// In DesignState
roomOverrides: {
"eg-wohnzimmer": {
floorColor: "#a0522d",
wallColor: "#f0e0d0"
}
}
The renderer checks for overrides before falling back to theme defaults.
Implementation Priority
| Step | What | Complexity |
|---|---|---|
| 1 | Define 5 theme presets | Low |
| 2 | Theme application (clear cache + re-render) | Low |
| 3 | Theme selector UI | Low |
| 4 | Smooth transition (fade between themes) | Medium |
| 5 | Per-room color overrides | Medium |
| 6 | Custom theme builder | High |
Feature 4: Export
Export Formats
| Format | What | Use Case |
|---|---|---|
| JSON | Design state (furniture placements) | Save/load, share |
| PNG | Screenshot of current view | Quick sharing |
| 2D floor plan with furniture | Printing | |
| glTF | 3D model of entire house | Use in other 3D apps |
JSON Save/Load
export class ExportManager {
constructor(renderer, state) {
this.renderer = renderer;
this.state = state;
}
// Save design to JSON
exportDesignJSON() {
const data = {
...this.state.design,
exportedAt: new Date().toISOString(),
theme: this.themeManager?.currentTheme || 'default'
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
this._download(blob, `${data.name || 'design'}.json`);
}
// Load design from JSON
async importDesignJSON(file) {
const text = await file.text();
const data = JSON.parse(text);
this.state.loadDesign(data);
this.renderer._clearFloor();
this.renderer.designData = data;
this.renderer._placeFurnitureForFloor();
}
}
PNG Screenshot
Three.js makes this straightforward:
exportScreenshot(width = 1920, height = 1080) {
// Temporarily resize renderer for high-res capture
const prevSize = new THREE.Vector2();
this.renderer.renderer.getSize(prevSize);
this.renderer.renderer.setSize(width, height);
this.renderer.camera.aspect = width / height;
this.renderer.camera.updateProjectionMatrix();
this.renderer.renderer.render(this.renderer.scene, this.renderer.camera);
const dataURL = this.renderer.renderer.domElement.toDataURL('image/png');
// Restore original size
this.renderer.renderer.setSize(prevSize.x, prevSize.y);
this.renderer.camera.aspect = prevSize.x / prevSize.y;
this.renderer.camera.updateProjectionMatrix();
this._downloadDataURL(dataURL, 'house-design.png');
}
Enhancement: Option to hide UI overlays (sidebar, info bar) during capture, or render to an offscreen canvas.
PDF Floor Plan (Phase 2)
Generate a 2D top-down floor plan suitable for printing:
- Create a 2D canvas (or use jsPDF)
- Draw rooms as rectangles with labels
- Draw furniture as simplified top-down shapes
- Add dimension lines and measurements
- Include legend with furniture names
This is a separate rendering pipeline from the 3D view. Consider using the canvas API directly or a library like jspdf + html2canvas.
exportFloorPlanPDF() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scale = 50; // 50px per meter
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
// ... draw rooms, furniture, dimensions, legend
// Convert to PDF using jsPDF or similar
}
glTF Export (Phase 3)
Three.js has a built-in GLTFExporter:
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
exportGLTF() {
const exporter = new GLTFExporter();
exporter.parse(this.renderer.scene, (gltf) => {
const blob = new Blob([JSON.stringify(gltf)], { type: 'application/json' });
this._download(blob, 'house-design.gltf');
}, { binary: false });
// Or binary .glb:
exporter.parse(this.renderer.scene, (buffer) => {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
this._download(blob, 'house-design.glb');
}, { binary: true });
}
Auto-Save
Save design state to localStorage periodically:
// Every 30 seconds, save to localStorage
setInterval(() => {
localStorage.setItem('house-design-autosave', JSON.stringify(this.state.design));
}, 30000);
// On load, offer to restore
const saved = localStorage.getItem('house-design-autosave');
if (saved) {
// Show "Restore previous session?" prompt
}
Implementation Priority
| Step | What | Complexity |
|---|---|---|
| 1 | JSON export (download) | Low |
| 2 | JSON import (file picker) | Low |
| 3 | PNG screenshot | Low |
| 4 | Auto-save to localStorage | Low |
| 5 | 2D floor plan PDF | High |
| 6 | glTF/GLB export | Medium |
| 7 | Share via URL (encode state) | Medium |
UI Design
Updated Sidebar Layout
┌─────────────────────────────┐
│ ☰ Musterhaus │
├─────────────────────────────┤
│ [View] [Edit] [Place] │ ← Mode buttons
├─────────────────────────────┤
│ FLOORS │
│ [EG] [OG] │
├─────────────────────────────┤
│ ROOMS │
│ ▸ Flur 18.0 m² │
│ ▸ Wohnzimmer 24.8 m² │ ← Expandable to show furniture
│ ▸ Küche 15.0 m² │
│ ... │
├─────────────────────────────┤
│ CATALOG 🔍 search │ ← Only visible in Place mode
│ ┌──────┐ ┌──────┐ │
│ │ Sofa │ │Chair │ │
│ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │Table │ │ Bed │ │
│ └──────┘ └──────┘ │
├─────────────────────────────┤
│ PROPERTIES │ ← Only visible when item selected
│ Sofa 3-Sitzer │
│ Position: (2.1, 3.4) │
│ Rotation: 180° │
│ [Rotate 90°] [Delete] │
├─────────────────────────────┤
│ THEME │
│ [Standard] [Modern] [Warm] │
│ [Dark] [Scandinavian] │
├─────────────────────────────┤
│ [💾 Save] [📷 Screenshot] │ ← Always visible
│ [📂 Load] [📤 Export 3D] │
└─────────────────────────────┘
Toolbar (Top)
For quick access to common actions:
┌─────────────────────────────────────────────────────┐
│ [Undo] [Redo] | [Snap: 0.25m ▾] | [Grid ✓] [Labels ✓] │
└─────────────────────────────────────────────────────┘
Keyboard Shortcuts
| Key | Action |
|---|---|
Escape |
Cancel current action, deselect |
Delete / Backspace |
Remove selected furniture |
R |
Rotate selected 90° clockwise |
Shift+R |
Rotate selected 90° counter-clockwise |
Ctrl+Z |
Undo |
Ctrl+Shift+Z |
Redo |
Ctrl+S |
Save design JSON |
G |
Toggle grid |
L |
Toggle room labels |
1-9 |
Quick-select room by index |
Required Renderer Changes
To support these features, HouseRenderer needs a few additions:
1. Make COLORS Exportable and Mutable
// Change from const to let, or export the object
export const COLORS = { ... }; // Already works — object properties are mutable
2. Add Furniture Click Detection
Extend _onClick to distinguish room clicks from furniture clicks:
_onClick(event) {
// ... existing raycaster setup ...
for (const hit of intersects) {
let obj = hit.object;
// Check furniture first (more specific)
while (obj && !obj.userData.isFurniture && !obj.userData.roomId) {
obj = obj.parent;
}
if (obj?.userData.isFurniture) {
this.container.dispatchEvent(new CustomEvent('furnitureclick', {
detail: { ...obj.userData, mesh: obj, point: hit.point }
}));
return;
}
if (obj?.userData.roomId) {
// existing room click behavior
}
}
}
3. Add OrbitControls Toggle
setControlsEnabled(enabled) {
this.controls.enabled = enabled;
}
4. Expose Scene for External Modules
The renderer already exposes this.scene, this.camera, this.raycaster as public properties. No changes needed — external modules can access these directly.
5. Floor-switch Event
showFloor(index) {
// ... existing code ...
this.container.dispatchEvent(new CustomEvent('floorchange', {
detail: { index, floor: this.houseData.floors[index] }
}));
}
Trade-off Analysis
Framework vs. Vanilla JS
Decision: Stay vanilla JS.
Pros of staying vanilla:
- No build step needed (works with static file server)
- No dependency management
- Project already works well this way
- Interactive features can be added as ES6 modules
Cons:
- No reactive UI updates (must manually sync DOM)
- No component system for sidebar panels
- State management is DIY
Mitigation: Keep UI simple. Use custom events for cross-module communication. The sidebar can be built with template literals and direct DOM manipulation — it doesn't need React for this complexity level.
2D vs. 3D Interaction
Decision: 3D interaction (existing stack).
The RESEARCH.md recommended a 2D Konva.js approach, but the project already has a working 3D viewer with raycasting. Adding drag-and-drop to 3D is harder than 2D, but:
- We already have raycasting and hit detection
- Floor plane projection for drag is straightforward
- Users already understand the 3D orbit camera
- A 2D top-down view can be added later as an alternative mode
Undo/Redo Approach
Decision: Full state snapshots with structuredClone.
Alternative: Command pattern (store individual operations). More memory-efficient but harder to implement correctly.
For a house design with ~50 furniture items, the design JSON is ~10-20KB. Storing 50 undo snapshots = ~1MB. That's fine. Simplicity wins.
Collision Detection
Decision: Defer to Phase 2.
AABB collision detection is useful but not critical for MVP. Users can visually avoid overlaps. Implement only if users find placement confusing without it.
Implementation Roadmap
Sprint 1: Foundation (3-4 tasks)
- DesignState class — observable state with undo/redo
- Renderer events —
furnitureclick,floorchange, controls toggle - InteractionManager skeleton — mode system, keyboard shortcuts
- Selection visual — outline on selected furniture
Sprint 2: Drag & Drop (3-4 tasks)
- Drag-to-move — floor plane projection, OrbitControls toggle
- Grid snapping — configurable snap size
- Room bounds constraint — keep furniture in rooms
- Rotate & delete — R key, Delete key
Sprint 3: Catalog & Placement (2-3 tasks)
- Catalog sidebar panel — browsable, filterable
- Click-to-place — new furniture from catalog
- Ghost preview — semi-transparent placement preview
Sprint 4: Themes & Export (3-4 tasks)
- Theme system — 5 presets, apply/switch
- Theme selector UI
- JSON save/load
- PNG screenshot
Sprint 5: Room Editing & Polish (3-4 tasks)
- Room property panel — read-only info
- Editable flooring/name
- Auto-save to localStorage
- Toolbar with undo/redo buttons
Future Sprints
- Room resize handles
- 2D floor plan export (PDF)
- glTF 3D export
- Collision detection
- Per-room color overrides
- Door/window editing
Task Breakdown for mai
These are the tasks to create for implementation:
1. "Create DesignState class with undo/redo" (coder)
2. "Add furniture click events and controls toggle to renderer" (coder)
3. "Build InteractionManager with mode system and keyboard shortcuts" (coder)
4. "Implement furniture selection with outline visual" (coder)
5. "Add drag-to-move furniture on floor plane" (coder)
6. "Add grid snapping and room bounds constraint" (coder)
7. "Build catalog sidebar panel with categories and search" (coder)
8. "Implement click-to-place new furniture from catalog" (coder)
9. "Create theme system with 5 presets" (coder)
10. "Add theme selector UI to sidebar" (coder)
11. "Implement JSON save/load and PNG screenshot export" (coder)
12. "Add room property panel and editable properties" (coder)
13. "Add toolbar with undo/redo, grid toggle, snap settings" (coder)
14. "Implement auto-save to localStorage" (coder)
Summary
This design adds full interactivity to the house viewer while respecting the existing architecture:
- No framework rewrite — vanilla JS modules that compose with HouseRenderer
- No new dependencies — everything built with Three.js and browser APIs
- Incremental delivery — each sprint produces a usable improvement
- Clean separation — interaction, themes, export, and UI are independent modules
- Undo/redo from day 1 — critical for any editor
- 14 implementation tasks, roughly 5 sprints