Files
house-design/DESIGN-interactive-features.md
m ab3e8fd03c Add research and interactive features design docs
- RESEARCH.md: Tech stack analysis (Three.js, SVG, hybrid approaches)
- DESIGN-interactive-features.md: Phase 2 design with 5 sprints, 14 tasks
2026-02-07 12:31:08 +01:00

30 KiB
Raw Blame History

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.

// 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:

  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.

_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
_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:

_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:

_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:

  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.

_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

// 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
PDF 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:

  1. Create a 2D canvas (or use jsPDF)
  2. Draw rooms as rectangles with labels
  3. Draw furniture as simplified top-down shapes
  4. Add dimension lines and measurements
  5. 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:

  1. We already have raycasting and hit detection
  2. Floor plane projection for drag is straightforward
  3. Users already understand the 3D orbit camera
  4. 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)

  1. DesignState class — observable state with undo/redo
  2. Renderer eventsfurnitureclick, floorchange, controls toggle
  3. InteractionManager skeleton — mode system, keyboard shortcuts
  4. Selection visual — outline on selected furniture

Sprint 2: Drag & Drop (3-4 tasks)

  1. Drag-to-move — floor plane projection, OrbitControls toggle
  2. Grid snapping — configurable snap size
  3. Room bounds constraint — keep furniture in rooms
  4. Rotate & delete — R key, Delete key

Sprint 3: Catalog & Placement (2-3 tasks)

  1. Catalog sidebar panel — browsable, filterable
  2. Click-to-place — new furniture from catalog
  3. Ghost preview — semi-transparent placement preview

Sprint 4: Themes & Export (3-4 tasks)

  1. Theme system — 5 presets, apply/switch
  2. Theme selector UI
  3. JSON save/load
  4. PNG screenshot

Sprint 5: Room Editing & Polish (3-4 tasks)

  1. Room property panel — read-only info
  2. Editable flooring/name
  3. Auto-save to localStorage
  4. Toolbar with undo/redo buttons

Future Sprints

  1. Room resize handles
  2. 2D floor plan export (PDF)
  3. glTF 3D export
  4. Collision detection
  5. Per-room color overrides
  6. 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