Compare commits

..

10 Commits

Author SHA1 Message Date
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
m
32eaf70635 Add catalog sidebar panel with categories, search, and click-to-place 2026-02-07 12:27:11 +01:00
m
e10abf4cf3 Add preserveDrawingBuffer for reliable PNG screenshot export
Without this flag, toDataURL() on the WebGL canvas can return blank
in some browsers because the drawing buffer gets cleared.
2026-02-07 12:26:30 +01:00
m
4d4d5f947b Add JSON save/load and PNG screenshot export
- ExportManager with JSON export (toJSON + timestamp), import with
  file picker and validation, and high-res PNG screenshot capture
- Save/Load/Screenshot buttons in sidebar
- Ctrl+S keyboard shortcut for quick save
- Design load event updates info bar
2026-02-07 12:26:05 +01:00
m
08248c6cad Add theme system with 5 presets and selector UI
- 5 themes: Standard, Modern, Warm, Dark, Scandinavian
- ThemeManager mutates shared COLORS, clears cache, re-renders
- Updates scene background and light intensities per theme
- Theme selector buttons with color swatch in sidebar
- Exported COLORS from renderer.js for theme access
2026-02-07 12:24:58 +01:00
m
c0368f9f01 Add drag-to-move furniture with grid snapping and room bounds
- Pointer down on selected furniture starts drag mode
- OrbitControls disabled during drag
- Floor plane (Y=0) raycasting for cursor projection
- Drag offset preserves grab point (no cursor jump)
- Grid snapping at 0.25m intervals (configurable)
- Room bounds constraint keeps furniture inside walls
- On pointer up, commits new position to DesignState
- Wall-mounted items excluded from drag
2026-02-07 12:23:27 +01:00
m
d0d9deb03a 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
2026-02-07 12:22:06 +01:00
m
36bc0aedd7 Add DesignState class with observable state and undo/redo
Lightweight state management for furniture editing. All mutations go
through named methods that snapshot state for undo, then notify
listeners. Supports move, rotate, add, remove, full design load,
and serialization via toJSON().
2026-02-07 12:20:03 +01:00
m
35300aa57a Add furniture click events, OrbitControls toggle, and floor change event
- Export COLORS for external module access (themes)
- Extend _onClick to detect furniture clicks before room clicks,
  dispatching 'furnitureclick' with catalog/room/mesh details
- Add setControlsEnabled(enabled) to toggle OrbitControls
- Dispatch 'floorchange' event from showFloor()
- Wire up furnitureclick in index.html to show item info
2026-02-07 12:19:19 +01:00
m
bf4eee8595 Improve 3D renderer: shadow camera, caching, error handling, labels
Renderer improvements:
- Configure shadow camera frustum to cover full house (was default -5..5)
- Add geometry and material caching to reduce GPU allocations
- Add proper disposal of Three.js objects on floor switch (fix memory leak)
- Add error handling for fetch failures with custom event dispatch
- Add room labels as sprites floating in each room
- Support wall-mounted furniture Y positioning via position.y
- Use cached highlight materials instead of mutating shared materials
2026-02-07 12:02:04 +01:00
9 changed files with 2853 additions and 69 deletions

View File

@@ -0,0 +1,985 @@
# 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
<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:
```javascript
// 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
```javascript
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:
```javascript
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`.
```javascript
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`:
```javascript
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:
```javascript
// 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
```javascript
// 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:
```javascript
_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
```javascript
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
```javascript
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 events**`furnitureclick`, `floorchange`, controls toggle
3. **InteractionManager skeleton** — mode system, keyboard shortcuts
4. **Selection visual** — outline on selected furniture
### Sprint 2: Drag & Drop (3-4 tasks)
5. **Drag-to-move** — floor plane projection, OrbitControls toggle
6. **Grid snapping** — configurable snap size
7. **Room bounds constraint** — keep furniture in rooms
8. **Rotate & delete** — R key, Delete key
### Sprint 3: Catalog & Placement (2-3 tasks)
9. **Catalog sidebar panel** — browsable, filterable
10. **Click-to-place** — new furniture from catalog
11. **Ghost preview** — semi-transparent placement preview
### Sprint 4: Themes & Export (3-4 tasks)
12. **Theme system** — 5 presets, apply/switch
13. **Theme selector UI**
14. **JSON save/load**
15. **PNG screenshot**
### Sprint 5: Room Editing & Polish (3-4 tasks)
16. **Room property panel** — read-only info
17. **Editable flooring/name**
18. **Auto-save to localStorage**
19. **Toolbar with undo/redo buttons**
### Future Sprints
20. Room resize handles
21. 2D floor plan export (PDF)
22. glTF 3D export
23. Collision detection
24. Per-room color overrides
25. 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

256
RESEARCH.md Normal file
View File

@@ -0,0 +1,256 @@
# Interior Design Visualization - Tech Stack Research
**Task:** t-fb166
**Date:** 2026-02-07
**Researcher:** bohr
## Executive Summary
**Recommended approach: 2D-first with Konva.js (react-konva), Vite+React app, with optional 3D preview via React Three Fiber later.**
This is the simplest viable path to a working room planner prototype. Start with 2D canvas-based floor plan editing, add 3D viewing as a second phase.
---
## Options Evaluated
### Option A: Pure 3D with React Three Fiber (R3F)
**Stack:** Vite + React + @react-three/fiber + @react-three/drei
**How it works:** Everything rendered in a WebGL 3D scene. Room walls are 3D meshes, furniture are 3D models (GLTF/GLB), camera orbits around the scene.
**Pros:**
- Visually impressive - realistic lighting, shadows, materials
- R3F is mature (huge community, active maintenance, excellent docs)
- drei provides orbit controls, drag controls, environment maps out of the box
- Good ecosystem of 3D furniture models (Sketchfab, etc.)
**Cons:**
- **Hardest to build** - 3D drag-and-drop is complex (raycasting, plane projection)
- Precise measurement/placement is difficult in 3D
- Requires 3D model assets for all furniture (heavy, complex pipeline)
- Performance concerns with many objects + shadows
- Steep learning curve for 3D math (vectors, quaternions, raycasting)
- Drawing room outlines in 3D is unintuitive for users
**Complexity: HIGH** | **Time to prototype: 3-4 weeks**
**Key repos:**
- [threejs-3d-room-designer](https://github.com/CodeHole7/threejs-3d-room-designer) - React + Three.js room planner
- [threejs-room-planner](https://github.com/nickorzha/threejs-room-planner) - Similar approach
---
### Option B: Hybrid 2D Editor + 3D Preview (react-planner style)
**Stack:** Vite + React + SVG (2D) + Three.js (3D preview)
**How it works:** Users draw/edit floor plans in a 2D SVG view, then toggle to a 3D preview. The same data model drives both views.
**Pros:**
- Best UX - 2D for precise editing, 3D for visualization
- Proven concept (react-planner has 1.4k GitHub stars)
- 2D editing is simpler and more intuitive for users
- 3D adds wow factor for presentations
**Cons:**
- Must maintain two rendering paths (SVG + Three.js)
- react-planner itself is **outdated** (React 16, Redux, Immutable.js - needs modernization)
- More code to maintain than either pure approach
- SVG can become slow with very complex plans
**Complexity: MEDIUM-HIGH** | **Time to prototype: 2-3 weeks**
**Key repos:**
- [react-planner](https://github.com/cvdlab/react-planner) - 1.4k stars, SVG 2D + Three.js 3D, but old React stack
- [arcada](https://github.com/mehanix/arcada) - React + Pixi.js, 196 stars, includes backend
---
### Option C: 2D Canvas with Konva.js (RECOMMENDED)
**Stack:** Vite + React + react-konva
**How it works:** Room plans rendered on HTML5 Canvas via Konva.js. Rooms are drawn as polygons/rectangles, furniture items are draggable shapes/images. Top-down 2D view.
**Pros:**
- **Simplest to build** - Konva handles drag-drop, transforms, snapping natively
- react-konva integrates cleanly with React component model
- Canvas performs well with hundreds of objects
- Built-in: drag-and-drop, resize handles, rotation, snapping, grouping, layers
- Easy to add grid snapping, measurements, labels
- Export to PNG/PDF straightforward
- Familiar 2D interaction model for users (like Google Slides)
- Can add 3D preview later using same data model
**Cons:**
- No 3D visualization (initially)
- Less visually impressive than 3D
- Canvas text rendering less crisp than SVG (mitigated by high-DPI)
**Complexity: LOW-MEDIUM** | **Time to prototype: 1-2 weeks**
**Alternative: Fabric.js** - Similar capabilities, also canvas-based, slightly different API. Konva has better React integration via react-konva.
---
### Option D: 2D SVG with React
**Stack:** Vite + React + raw SVG (or svg.js)
**How it works:** Floor plans rendered as SVG elements directly in React. Furniture items are SVG groups with drag behavior.
**Pros:**
- SVG is resolution-independent (crisp at all zoom levels)
- React manages SVG elements naturally (they're just DOM nodes)
- Good for precise measurements and labels
- Easy to style with CSS
**Cons:**
- Performance degrades with many elements (SVG is DOM-based)
- Custom drag-and-drop implementation needed (no built-in like Konva)
- Transform handles, snapping, rotation all manual work
- More boilerplate than Konva for interactive features
**Complexity: MEDIUM** | **Time to prototype: 2 weeks**
---
## Comparison Matrix
| Criteria | A: Pure 3D (R3F) | B: Hybrid 2D+3D | C: 2D Konva (rec) | D: 2D SVG |
|-----------------------|-------------------|------------------|---------------------|-----------|
| Time to prototype | 3-4 weeks | 2-3 weeks | **1-2 weeks** | 2 weeks |
| Visual impact | **Highest** | High | Medium | Medium |
| Ease of furniture add | Hard (3D models) | Medium | **Easy (images)** | Easy |
| Drag-and-drop | Complex | Medium | **Built-in** | Manual |
| Precise placement | Hard | Good | **Good** | Good |
| Performance | Medium | Medium | **Good** | Medium |
| Upgrade path to 3D | N/A | Built-in | **Add later** | Add later |
| Learning curve | Steep | Medium | **Low** | Low |
| Mobile support | Limited | Limited | **Good (touch)** | Good |
---
## Recommended Architecture
### Phase 1: 2D Room Planner (MVP)
```
Tech Stack:
- Vite + React + TypeScript
- react-konva (2D canvas rendering)
- Zustand (lightweight state management)
- Tailwind CSS (UI styling)
- shadcn/ui (UI components)
Features:
- Draw room outlines (rectangles, L-shapes)
- Grid-based canvas with snap-to-grid
- Furniture catalog sidebar (chair, table, bed, sofa, etc.)
- Drag furniture from catalog onto room
- Move, rotate, resize placed furniture
- Room dimensions and measurements
- Export as PNG image
- Save/load room plans (JSON)
```
### Phase 2: Enhanced Features
```
- Upload room image as background trace layer
- More furniture with realistic top-down SVG/PNG icons
- Multiple rooms / full floor plans
- Wall thickness and door/window placement
- Measurement labels and area calculation
- Color/material swatches for floors and walls
```
### Phase 3: 3D Preview (Optional)
```
Add-on stack:
- @react-three/fiber + @react-three/drei
- Same room data model → 3D scene generation
- Orbit camera view of the room
- Basic lighting and materials
```
---
## Key Libraries
| Library | Purpose | npm Weekly Downloads | Maturity |
|---------|---------|---------------------|----------|
| react-konva | React bindings for Konva canvas | ~100k+ | Stable, active |
| konva | 2D canvas framework | ~200k+ | Stable, v9+ |
| zustand | State management | ~3M+ | Very active |
| @react-three/fiber | React Three.js (Phase 3) | ~500k+ | Very active |
| @react-three/drei | R3F helpers (Phase 3) | ~500k+ | Very active |
---
## Existing Projects Worth Studying
1. **[react-planner](https://github.com/cvdlab/react-planner)** - Best reference for hybrid 2D/3D architecture. Study its data model even if not using the code directly (it's outdated React 16).
2. **[arcada](https://github.com/mehanix/arcada)** - React + Pixi.js floor planner. Good reference for feature set and UX patterns. Has a [bachelor's thesis document](https://github.com/mehanix/arcada/blob/master/docs/) explaining the architecture.
3. **[Floorplan.js](https://floorplanjs.org/)** - Commercial-ish but has good UX patterns to study.
4. **Sweet Home 3D** - Java desktop app, but gold standard for feature set reference. Shows what users expect from a room planner.
---
## Data Model Sketch
```typescript
interface RoomPlan {
id: string;
name: string;
rooms: Room[];
furniture: FurnitureItem[];
}
interface Room {
id: string;
points: Point[]; // polygon vertices
label: string;
floorColor: string;
}
interface FurnitureItem {
id: string;
catalogId: string; // reference to catalog
x: number;
y: number;
rotation: number; // degrees
scaleX: number;
scaleY: number;
}
interface CatalogEntry {
id: string;
name: string;
category: string;
width: number; // real-world cm
height: number;
icon: string; // SVG/PNG path for 2D view
model3d?: string; // GLTF path for future 3D view
}
```
This data model works for both 2D rendering (Konva) and future 3D rendering (R3F), enabling a clean upgrade path.
---
## Conclusion
**Start with Option C (2D Konva.js)** because:
1. Fastest path to a working prototype
2. Konva handles 90% of the interactive features we need out of the box
3. Clean data model that separates room geometry from rendering
4. 3D can be added as a second view of the same data later
5. Modern stack (Vite + React + TS + Zustand) with excellent DX
6. Low risk - all well-maintained, well-documented libraries

233
src/catalog.js Normal file
View File

@@ -0,0 +1,233 @@
/**
* CatalogPanel — left sidebar for browsing furniture catalog.
*
* Shows categories, search, and item cards. Clicking an item
* adds it to the center of the selected room via DesignState.
*/
export class CatalogPanel {
constructor(container, { renderer, state, interaction }) {
this.container = container;
this.renderer = renderer;
this.state = state;
this.interaction = interaction;
this.selectedCategory = 'all';
this.searchQuery = '';
this.selectedRoomId = null;
this._build();
this._bindEvents();
}
// ---- Build DOM ----
_build() {
this.container.innerHTML = '';
this.container.className = 'catalog-panel';
// Search
const searchWrap = document.createElement('div');
searchWrap.className = 'catalog-search';
this._searchInput = document.createElement('input');
this._searchInput.type = 'text';
this._searchInput.placeholder = 'Search furniture...';
this._searchInput.className = 'catalog-search-input';
searchWrap.appendChild(this._searchInput);
this.container.appendChild(searchWrap);
// Categories
this._categoryBar = document.createElement('div');
this._categoryBar.className = 'catalog-categories';
this.container.appendChild(this._categoryBar);
// Items list
this._itemList = document.createElement('div');
this._itemList.className = 'catalog-items';
this.container.appendChild(this._itemList);
this._renderCategories();
this._renderItems();
}
_renderCategories() {
const catalog = this.renderer.catalogData;
if (!catalog) return;
this._categoryBar.innerHTML = '';
const categories = ['all', ...catalog.categories];
const LABELS = {
all: 'All',
seating: 'Seating',
tables: 'Tables',
storage: 'Storage',
beds: 'Beds',
bathroom: 'Bath',
kitchen: 'Kitchen',
office: 'Office',
lighting: 'Lighting',
decor: 'Decor'
};
for (const cat of categories) {
const btn = document.createElement('button');
btn.className = 'catalog-cat-btn' + (cat === this.selectedCategory ? ' active' : '');
btn.textContent = LABELS[cat] || cat;
btn.dataset.category = cat;
btn.addEventListener('click', () => {
this.selectedCategory = cat;
this._renderCategories();
this._renderItems();
});
this._categoryBar.appendChild(btn);
}
}
_renderItems() {
const catalog = this.renderer.catalogData;
if (!catalog) {
this._itemList.innerHTML = '<div class="catalog-empty">No catalog loaded</div>';
return;
}
let items = catalog.items;
// Filter by category
if (this.selectedCategory !== 'all') {
items = items.filter(it => it.category === this.selectedCategory);
}
// Filter by search
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
items = items.filter(it =>
it.name.toLowerCase().includes(q) ||
it.id.toLowerCase().includes(q) ||
it.category.toLowerCase().includes(q)
);
}
this._itemList.innerHTML = '';
if (items.length === 0) {
this._itemList.innerHTML = '<div class="catalog-empty">No items found</div>';
return;
}
for (const item of items) {
const card = this._createItemCard(item);
this._itemList.appendChild(card);
}
}
_createItemCard(item) {
const card = document.createElement('div');
card.className = 'catalog-item';
card.dataset.catalogId = item.id;
// Color swatch from first part
const color = item.mesh?.parts?.[0]?.color || '#888';
const dims = item.dimensions;
const dimStr = `${dims.width}×${dims.depth}×${dims.height}m`;
card.innerHTML =
`<div class="catalog-item-swatch" style="background:${color}"></div>` +
`<div class="catalog-item-info">` +
`<div class="catalog-item-name">${item.name}</div>` +
`<div class="catalog-item-dims">${dimStr}</div>` +
`</div>` +
`<button class="catalog-item-add" title="Add to room">+</button>`;
card.querySelector('.catalog-item-add').addEventListener('click', (e) => {
e.stopPropagation();
this._placeItem(item);
});
card.addEventListener('click', () => {
this._placeItem(item);
});
return card;
}
// ---- Place item ----
_placeItem(catalogItem) {
// Determine target room
const roomId = this._getTargetRoom(catalogItem);
if (!roomId) return;
// Get room center for initial placement
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
const room = floor?.rooms.find(r => r.id === roomId);
if (!room) return;
// Place at room center (local coords)
const cx = room.dimensions.width / 2;
const cz = room.dimensions.length / 2;
const placement = {
catalogId: catalogItem.id,
position: { x: cx, z: cz },
rotation: 0,
wallMounted: false
};
// InteractionManager handles the re-render via furniture-add event
this.state.addFurniture(roomId, placement);
}
_getTargetRoom(catalogItem) {
// Use currently selected room from interaction manager or sidebar
if (this.selectedRoomId) {
return this.selectedRoomId;
}
// If interaction has a room selected (via furniture selection)
if (this.interaction.selectedRoomId) {
return this.interaction.selectedRoomId;
}
// Try to find a matching room on the current floor
const rooms = this.renderer.getRooms();
if (rooms.length === 0) return null;
// If catalog item has room hints, try to match
if (catalogItem.rooms && catalogItem.rooms.length > 0) {
for (const room of rooms) {
// Room ids contain the room type (eg "eg-wohnzimmer")
for (const hint of catalogItem.rooms) {
if (room.id.includes(hint)) return room.id;
}
}
}
// Fallback: first room on the floor
return rooms[0].id;
}
// ---- Events ----
_bindEvents() {
this._searchInput.addEventListener('input', () => {
this.searchQuery = this._searchInput.value.trim();
this._renderItems();
});
// Track room selection from main sidebar
this.renderer.container.addEventListener('roomclick', (e) => {
this.selectedRoomId = e.detail.roomId;
});
}
/** Called externally when a room is selected in the main sidebar. */
setSelectedRoom(roomId) {
this.selectedRoomId = roomId;
}
/** Refresh the item list (e.g., after floor change). */
refresh() {
this._renderItems();
}
}

124
src/export.js Normal file
View File

@@ -0,0 +1,124 @@
import * as THREE from 'three';
/**
* ExportManager — JSON save/load and PNG screenshot export.
*
* Receives HouseRenderer + DesignState, provides download/upload
* functionality for design files and viewport screenshots.
*/
export class ExportManager {
constructor(renderer, state) {
this.renderer = renderer;
this.state = state;
}
/** Export current design state as a downloadable JSON file. */
exportDesignJSON() {
const data = {
...this.state.toJSON(),
exportedAt: new Date().toISOString()
};
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
this._downloadBlob(blob, `${data.name || 'design'}.json`);
}
/** Open a file picker and load a design JSON file. */
importDesignJSON() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
this._loadDesignFile(file);
});
input.click();
}
/** Load a design from a File object. */
async _loadDesignFile(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
// Basic validation: must have rooms array
if (!data.rooms || !Array.isArray(data.rooms)) {
throw new Error('Invalid design file: missing rooms array');
}
this.state.loadDesign(data);
this.renderer.designData = this.state.design;
// Re-render current floor with new 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();
this.renderer.container.dispatchEvent(new CustomEvent('designloaded', {
detail: { name: data.name || file.name }
}));
} catch (err) {
console.error('Failed to load design:', err);
this.renderer.container.dispatchEvent(new CustomEvent('loaderror', {
detail: { source: 'importDesign', error: err.message }
}));
}
}
/**
* Capture the current 3D view as a PNG and download it.
* Temporarily resizes the renderer for high-res output.
*/
exportScreenshot(width = 1920, height = 1080) {
const r = this.renderer.renderer;
// Save current size
const prevSize = new THREE.Vector2();
r.getSize(prevSize);
const prevPixelRatio = r.getPixelRatio();
// Resize for capture
r.setPixelRatio(1);
r.setSize(width, height);
this.renderer.camera.aspect = width / height;
this.renderer.camera.updateProjectionMatrix();
// Render one frame
r.render(this.renderer.scene, this.renderer.camera);
// Grab the image
const dataURL = r.domElement.toDataURL('image/png');
// Restore original size
r.setPixelRatio(prevPixelRatio);
r.setSize(prevSize.x, prevSize.y);
this.renderer.camera.aspect = prevSize.x / prevSize.y;
this.renderer.camera.updateProjectionMatrix();
this._downloadDataURL(dataURL, 'house-design.png');
}
// ---- Internal helpers ----
_downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
_downloadDataURL(dataURL, filename) {
const a = document.createElement('a');
a.href = dataURL;
a.download = filename;
a.click();
}
}

View File

@@ -52,10 +52,32 @@
.room-item.active { background: #4a90d9; color: #fff; }
.room-item .area { font-size: 11px; opacity: 0.7; }
.theme-btn {
display: inline-block;
padding: 4px 10px;
margin: 2px 3px 2px 0;
border: 2px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1.4;
}
.theme-btn.active { border-color: #4a90d9; }
.theme-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
vertical-align: middle;
margin-right: 4px;
border: 1px solid rgba(0,0,0,0.15);
}
#info {
position: fixed;
bottom: 16px;
left: 16px;
left: 260px;
background: rgba(0,0,0,0.7);
color: #fff;
padding: 8px 14px;
@@ -63,20 +85,169 @@
font-size: 13px;
z-index: 10;
}
/* Catalog panel (left sidebar) */
#catalog-panel {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
border-right: 1px solid #ddd;
z-index: 10;
display: flex;
flex-direction: column;
}
.catalog-panel h3 {
font-size: 13px;
padding: 12px 12px 0;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.catalog-search {
padding: 12px 12px 8px;
}
.catalog-search-input {
width: 100%;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.catalog-search-input:focus {
border-color: #4a90d9;
}
.catalog-categories {
padding: 0 12px 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.catalog-cat-btn {
padding: 3px 8px;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 11px;
}
.catalog-cat-btn.active {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.catalog-items {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
}
.catalog-item {
display: flex;
align-items: center;
padding: 8px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.catalog-item:hover {
background: #e8f0fe;
}
.catalog-item-swatch {
width: 32px;
height: 32px;
border-radius: 4px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.1);
}
.catalog-item-info {
flex: 1;
margin-left: 8px;
min-width: 0;
}
.catalog-item-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.catalog-item-dims {
font-size: 10px;
color: #888;
margin-top: 2px;
}
.catalog-item-add {
width: 24px;
height: 24px;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
color: #4a90d9;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.catalog-item-add:hover {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.catalog-empty {
text-align: center;
color: #999;
padding: 20px 0;
font-size: 12px;
}
.export-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #ddd;
}
.export-btn {
display: inline-block;
padding: 6px 12px;
margin: 3px 4px 3px 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.export-btn:hover { background: #f0f0f0; }
.export-btn:active { background: #e0e0e0; }
</style>
</head>
<body>
<div id="viewer"></div>
<div id="catalog-panel"></div>
<div id="sidebar">
<h2 id="house-name">Loading...</h2>
<h3>Floors</h3>
<div id="floor-buttons"></div>
<h3>Rooms</h3>
<div id="room-list"></div>
<h3>Theme</h3>
<div id="theme-buttons"></div>
<div class="export-section">
<h3>File</h3>
<button class="export-btn" id="btn-save">Save JSON</button>
<button class="export-btn" id="btn-load">Load JSON</button>
<button class="export-btn" id="btn-screenshot">Screenshot</button>
</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">
{
@@ -88,33 +259,71 @@
</script>
<script type="module">
import { HouseRenderer } from './renderer.js';
import { DesignState } from './state.js';
import { InteractionManager } from './interaction.js';
import { ThemeManager } from './themes.js';
import { ExportManager } from './export.js';
import { CatalogPanel } from './catalog.js';
const viewer = document.getElementById('viewer');
const renderer = new HouseRenderer(viewer);
const houseRenderer = new HouseRenderer(viewer);
let selectedRoom = null;
let designState = null;
let interaction = null;
let themeManager = null;
let exportManager = null;
let catalogPanel = 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;
await renderer.loadCatalog('../data/furniture-catalog.json');
await renderer.loadDesign('../designs/sample-house-design.json');
await houseRenderer.loadCatalog('../data/furniture-catalog.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.';
}
});
themeManager = new ThemeManager(houseRenderer);
exportManager = new ExportManager(houseRenderer, designState);
catalogPanel = new CatalogPanel(document.getElementById('catalog-panel'), {
renderer: houseRenderer,
state: designState,
interaction
});
buildFloorButtons();
buildRoomList();
buildThemeButtons();
wireExportButtons();
}).catch(err => {
document.getElementById('house-name').textContent = 'Error loading data';
document.getElementById('info').textContent = err.message;
});
function buildFloorButtons() {
const container = document.getElementById('floor-buttons');
container.innerHTML = '';
for (const floor of renderer.getFloors()) {
for (const floor of houseRenderer.getFloors()) {
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.addEventListener('click', () => {
renderer.showFloor(floor.index);
houseRenderer.showFloor(floor.index);
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
buildRoomList();
selectedRoom = null;
if (catalogPanel) catalogPanel.setSelectedRoom(null);
});
container.appendChild(btn);
}
@@ -123,7 +332,7 @@
function buildRoomList() {
const container = document.getElementById('room-list');
container.innerHTML = '';
for (const room of renderer.getRooms()) {
for (const room of houseRenderer.getRooms()) {
const item = document.createElement('div');
item.className = 'room-item';
item.dataset.roomId = room.id;
@@ -135,11 +344,12 @@
function selectRoom(roomId) {
selectedRoom = roomId;
renderer.focusRoom(roomId);
houseRenderer.focusRoom(roomId);
document.querySelectorAll('.room-item').forEach(el => {
el.classList.toggle('active', el.dataset.roomId === roomId);
});
const room = renderer.getRooms().find(r => r.id === roomId);
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
const room = houseRenderer.getRooms().find(r => r.id === roomId);
if (room) {
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area}`;
}
@@ -148,6 +358,50 @@
viewer.addEventListener('roomclick', (e) => {
selectRoom(e.detail.roomId);
});
function buildThemeButtons() {
const container = document.getElementById('theme-buttons');
container.innerHTML = '';
for (const theme of themeManager.getThemes()) {
const btn = document.createElement('button');
btn.className = 'theme-btn' + (theme.id === themeManager.currentTheme ? ' active' : '');
btn.innerHTML = `<span class="theme-swatch" style="background:${theme.swatch}"></span>${theme.name}`;
btn.addEventListener('click', () => {
themeManager.applyTheme(theme.id);
document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
buildRoomList(); // re-render room list since floor was rebuilt
});
container.appendChild(btn);
}
}
function wireExportButtons() {
document.getElementById('btn-save').addEventListener('click', () => {
exportManager.exportDesignJSON();
});
document.getElementById('btn-load').addEventListener('click', () => {
exportManager.importDesignJSON();
});
document.getElementById('btn-screenshot').addEventListener('click', () => {
exportManager.exportScreenshot();
});
// Ctrl+S / Cmd+S to save design
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (exportManager) {
exportManager.exportDesignJSON();
}
}
});
}
// Update info bar when a design is loaded from file
viewer.addEventListener('designloaded', (e) => {
document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`;
});
</script>
</body>
</html>

474
src/interaction.js Normal file
View File

@@ -0,0 +1,474 @@
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;
// Drag state
this._isDragging = false;
this._dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Y=0 floor plane
this._dragOffset = new THREE.Vector3();
this._dragStartPos = null; // world position at drag start
this.snapEnabled = true;
this.snapSize = 0.25; // metres
this._roomBounds = new Map();
this._furniturePadding = 0.05; // small buffer from walls
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._onPointerDown = this._onPointerDown.bind(this);
this._onPointerMove = this._onPointerMove.bind(this);
this._onPointerUp = this._onPointerUp.bind(this);
const canvas = this.renderer.renderer.domElement;
this.renderer.container.addEventListener('furnitureclick', this._onFurnitureClick);
this.renderer.container.addEventListener('roomclick', this._onRoomClick);
this.renderer.container.addEventListener('floorchange', this._onFloorChange);
canvas.addEventListener('pointerdown', this._onPointerDown);
canvas.addEventListener('pointermove', this._onPointerMove);
canvas.addEventListener('pointerup', this._onPointerUp);
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));
// Build initial room bounds
this._buildRoomBounds();
}
// ---- 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) {
// Don't handle click if we just finished a drag
if (this._wasDragging) {
this._wasDragging = false;
return;
}
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._isDragging = false;
this.selectedObject = null;
this.selectedRoomId = null;
this.selectedIndex = -1;
this._outlineGroup = null;
this._buildRoomBounds();
}
// ---- Drag handling ----
_getNDC(event) {
const rect = this.renderer.renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
}
_onPointerDown(event) {
if (event.button !== 0) return; // left click only
if (!this.selectedObject || this.selectedObject.userData.wallMounted) return;
// Raycast to check if we're clicking the selected furniture
const ndc = this._getNDC(event);
this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera);
const hits = this.renderer.raycaster.intersectObject(this.selectedObject, true);
if (hits.length === 0) return;
// Calculate drag offset so object doesn't jump to cursor
const intersection = new THREE.Vector3();
this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection);
if (!intersection) return;
this._dragOffset.copy(this.selectedObject.position).sub(intersection);
this._dragStartPos = this.selectedObject.position.clone();
this._isDragging = true;
this._wasDragging = false;
this.mode = 'move';
// Disable orbit controls during drag
this.renderer.setControlsEnabled(false);
}
_onPointerMove(event) {
if (!this._isDragging || !this.selectedObject) return;
const ndc = this._getNDC(event);
this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera);
const intersection = new THREE.Vector3();
this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection);
if (!intersection) return;
// Apply drag offset
intersection.add(this._dragOffset);
// Grid snapping (snap world position)
if (this.snapEnabled) {
intersection.x = Math.round(intersection.x / this.snapSize) * this.snapSize;
intersection.z = Math.round(intersection.z / this.snapSize) * this.snapSize;
}
// Room bounds constraint
const bounds = this._roomBounds.get(this.selectedRoomId);
if (bounds) {
const pad = this._furniturePadding;
intersection.x = clamp(intersection.x, bounds.minX + pad, bounds.maxX - pad);
intersection.z = clamp(intersection.z, bounds.minZ + pad, bounds.maxZ - pad);
}
// Keep original Y
intersection.y = this.selectedObject.position.y;
this.selectedObject.position.copy(intersection);
}
_onPointerUp(_event) {
if (!this._isDragging) return;
this._isDragging = false;
this.renderer.setControlsEnabled(true);
if (!this.selectedObject || !this._dragStartPos) {
this.mode = 'select';
return;
}
// Check if position actually changed
const moved = !this.selectedObject.position.equals(this._dragStartPos);
if (moved) {
this._wasDragging = true; // prevent click handler from re-selecting
// Convert world position back to room-local coords for state
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
const room = floor?.rooms.find(r => r.id === this.selectedRoomId);
if (room) {
const localX = this.selectedObject.position.x - room.position.x;
const localZ = this.selectedObject.position.z - room.position.y;
this.state.moveFurniture(this.selectedRoomId, this.selectedIndex, { x: localX, z: localZ });
}
}
this.mode = 'select';
this._dragStartPos = null;
}
// ---- Room bounds ----
_buildRoomBounds() {
this._roomBounds.clear();
if (!this.renderer.houseData) return;
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
if (!floor) return;
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
});
}
}
_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-add' || type === 'furniture-remove') {
// Re-render the floor to reflect added/removed furniture
if (type === 'furniture-remove') 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() {
const canvas = this.renderer.renderer.domElement;
this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick);
this.renderer.container.removeEventListener('roomclick', this._onRoomClick);
this.renderer.container.removeEventListener('floorchange', this._onFloorChange);
canvas.removeEventListener('pointerdown', this._onPointerDown);
canvas.removeEventListener('pointermove', this._onPointerMove);
canvas.removeEventListener('pointerup', this._onPointerUp);
window.removeEventListener('keydown', this._onKeyDown);
this._unsubState();
this._removeOutline();
this._outlineMaterial.dispose();
this._listeners.clear();
}
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}

View File

@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const COLORS = {
export const COLORS = {
wall: {
exterior: 0xe8e0d4,
interior: 0xf5f0eb
@@ -28,6 +28,8 @@ export class HouseRenderer {
this.roomMeshes = new Map();
this.roomLabels = new Map();
this.furnitureMeshes = new Map();
this._materialCache = new Map();
this._geometryCache = new Map();
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
@@ -41,7 +43,7 @@ export class HouseRenderer {
this.camera.position.set(6, 12, 14);
this.camera.lookAt(6, 0, 5);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.shadowMap.enabled = true;
@@ -74,6 +76,12 @@ export class HouseRenderer {
dir.castShadow = true;
dir.shadow.mapSize.width = 2048;
dir.shadow.mapSize.height = 2048;
dir.shadow.camera.left = -15;
dir.shadow.camera.right = 15;
dir.shadow.camera.top = 15;
dir.shadow.camera.bottom = -15;
dir.shadow.camera.near = 0.5;
dir.shadow.camera.far = 40;
this.scene.add(dir);
}
@@ -84,42 +92,56 @@ export class HouseRenderer {
}
async loadHouse(url) {
const res = await fetch(url);
this.houseData = await res.json();
this.showFloor(0);
return this.houseData;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load house: ${res.status} ${res.statusText}`);
this.houseData = await res.json();
this.showFloor(0);
return this.houseData;
} catch (err) {
this._emitError('loadHouse', err);
throw err;
}
}
async loadCatalog(url) {
const res = await fetch(url);
this.catalogData = await res.json();
this._catalogIndex = new Map();
for (const item of this.catalogData.items) {
this._catalogIndex.set(item.id, item);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
this.catalogData = await res.json();
this._catalogIndex = new Map();
for (const item of this.catalogData.items) {
this._catalogIndex.set(item.id, item);
}
return this.catalogData;
} catch (err) {
this._emitError('loadCatalog', err);
throw err;
}
return this.catalogData;
}
async loadDesign(url) {
const res = await fetch(url);
this.designData = await res.json();
this._placeFurnitureForFloor();
return this.designData;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load design: ${res.status} ${res.statusText}`);
this.designData = await res.json();
this._placeFurnitureForFloor();
return this.designData;
} catch (err) {
this._emitError('loadDesign', err);
throw err;
}
}
_emitError(source, error) {
this.container.dispatchEvent(new CustomEvent('loaderror', {
detail: { source, error: error.message }
}));
}
showFloor(index) {
this.currentFloor = index;
// Clear existing room meshes
for (const group of this.roomMeshes.values()) {
this.scene.remove(group);
}
this.roomMeshes.clear();
this.roomLabels.clear();
// Clear existing furniture
for (const group of this.furnitureMeshes.values()) {
this.scene.remove(group);
}
this.furnitureMeshes.clear();
this._clearFloor();
const floor = this.houseData.floors[index];
if (!floor) return;
@@ -129,6 +151,46 @@ export class HouseRenderer {
}
this._placeFurnitureForFloor();
this.container.dispatchEvent(new CustomEvent('floorchange', {
detail: { index, floor }
}));
}
setControlsEnabled(enabled) {
this.controls.enabled = enabled;
}
_clearFloor() {
for (const group of this.roomMeshes.values()) {
this.scene.remove(group);
this._disposeGroup(group);
}
this.roomMeshes.clear();
this.roomLabels.clear();
for (const group of this.furnitureMeshes.values()) {
this.scene.remove(group);
this._disposeGroup(group);
}
this.furnitureMeshes.clear();
// Dispose all cached GPU resources — they'll be recreated as needed
for (const geo of this._geometryCache.values()) geo.dispose();
this._geometryCache.clear();
for (const mat of this._materialCache.values()) {
if (mat.map) mat.map.dispose();
mat.dispose();
}
this._materialCache.clear();
}
_disposeGroup(group) {
group.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
});
}
_renderRoom(room, ceilingHeight) {
@@ -160,16 +222,67 @@ export class HouseRenderer {
this._addWall(group, wd, wallData, ceilingHeight);
}
// Room label
const label = this._createRoomLabel(room.name, width, length, ceilingHeight);
group.add(label);
this.roomLabels.set(room.id, label);
this.scene.add(group);
this.roomMeshes.set(room.id, group);
}
_createRoomLabel(name, width, length, ceilingHeight) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const fontSize = 48;
ctx.font = `bold ${fontSize}px sans-serif`;
const metrics = ctx.measureText(name);
const padding = 16;
canvas.width = metrics.width + padding * 2;
canvas.height = fontSize + padding * 2;
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(name, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(mat);
const scale = 0.008;
sprite.scale.set(canvas.width * scale, canvas.height * scale, 1);
sprite.position.set(width / 2, ceilingHeight * 0.4, length / 2);
return sprite;
}
_getCachedMaterial(key, createFn) {
let mat = this._materialCache.get(key);
if (!mat) {
mat = createFn();
this._materialCache.set(key, mat);
}
return mat;
}
_getCachedGeometry(key, createFn) {
let geo = this._geometryCache.get(key);
if (!geo) {
geo = createFn();
this._geometryCache.set(key, geo);
}
return geo;
}
_addFloor(group, width, length, flooring) {
const geo = new THREE.PlaneGeometry(width, length);
const mat = new THREE.MeshStandardMaterial({
color: COLORS.floor[flooring] || COLORS.floor.hardwood,
roughness: 0.8
});
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
const color = COLORS.floor[flooring] || COLORS.floor.hardwood;
const mat = this._getCachedMaterial(`floor:${color}`, () => new THREE.MeshStandardMaterial({ color, roughness: 0.8 }));
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(width / 2, 0, length / 2);
@@ -178,13 +291,13 @@ export class HouseRenderer {
}
_addCeiling(group, width, length, height) {
const geo = new THREE.PlaneGeometry(width, length);
const mat = new THREE.MeshStandardMaterial({
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
const mat = this._getCachedMaterial('ceiling', () => new THREE.MeshStandardMaterial({
color: COLORS.ceiling,
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide
});
}));
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = Math.PI / 2;
mesh.position.set(width / 2, height, length / 2);
@@ -202,13 +315,15 @@ export class HouseRenderer {
const wallWidth = wallDef.wallWidth;
const segments = this._computeWallSegments(openings, wallWidth, ceilingHeight);
const matKey = `wall:${wallColor}`;
const mat = this._getCachedMaterial(matKey, () => new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 }));
for (const seg of segments) {
const geo = new THREE.BoxGeometry(seg.w, seg.h, thickness);
const mat = new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 });
const geo = this._getCachedGeometry(`box:${seg.w}:${seg.h}:${thickness}`, () => new THREE.BoxGeometry(seg.w, seg.h, thickness));
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { isWall: true, roomId: group.userData.roomId };
mesh.userData = { isWall: true, roomId: group.userData.roomId, materialKey: matKey };
if (wallDef.horizontal) {
mesh.position.set(seg.cx, seg.cy, wallDef.pos);
@@ -274,8 +389,9 @@ export class HouseRenderer {
}
_addDoorMesh(group, wallDef, door, thickness) {
const geo = new THREE.BoxGeometry(door.width, door.height, thickness * 0.5);
const mat = new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 });
const t = thickness * 0.5;
const geo = this._getCachedGeometry(`box:${door.width}:${door.height}:${t}`, () => new THREE.BoxGeometry(door.width, door.height, t));
const mat = this._getCachedMaterial('door', () => new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 }));
const mesh = new THREE.Mesh(geo, mat);
const cx = door.position + door.width / 2;
@@ -292,13 +408,14 @@ export class HouseRenderer {
_addWindowMesh(group, wallDef, win, thickness) {
// Glass pane
const geo = new THREE.BoxGeometry(win.width, win.height, thickness * 0.3);
const mat = new THREE.MeshStandardMaterial({
const t = thickness * 0.3;
const geo = this._getCachedGeometry(`box:${win.width}:${win.height}:${t}`, () => new THREE.BoxGeometry(win.width, win.height, t));
const mat = this._getCachedMaterial('window', () => new THREE.MeshStandardMaterial({
color: COLORS.window,
transparent: true,
opacity: 0.4,
roughness: 0.1
});
}));
const mesh = new THREE.Mesh(geo, mat);
const cx = win.position + win.width / 2;
@@ -313,8 +430,8 @@ export class HouseRenderer {
group.add(mesh);
// Window frame
const frameGeo = new THREE.EdgesGeometry(geo);
const frameMat = new THREE.LineBasicMaterial({ color: COLORS.windowFrame });
const frameGeo = this._getCachedGeometry(`edges:${win.width}:${win.height}:${t}`, () => new THREE.EdgesGeometry(geo));
const frameMat = this._getCachedMaterial('windowFrame', () => new THREE.LineBasicMaterial({ color: COLORS.windowFrame }));
const frame = new THREE.LineSegments(frameGeo, frameMat);
frame.position.copy(mesh.position);
frame.rotation.copy(mesh.rotation);
@@ -322,21 +439,28 @@ export class HouseRenderer {
}
highlightRoom(roomId) {
// Reset all rooms
// Reset all rooms - restore original shared materials
for (const [id, group] of this.roomMeshes) {
group.traverse(child => {
if (child.isMesh && child.material && child.userData.isWall) {
child.material.emissive?.setHex(0x000000);
if (child.isMesh && child.userData.isWall && child.userData._origMaterial) {
child.material = child.userData._origMaterial;
delete child.userData._origMaterial;
}
});
}
// Highlight target
// Highlight target - swap to cached highlight variant
const target = this.roomMeshes.get(roomId);
if (target) {
target.traverse(child => {
if (child.isMesh && child.userData.isWall) {
child.material.emissive.setHex(0x111133);
const baseKey = child.userData.materialKey;
child.userData._origMaterial = child.material;
child.material = this._getCachedMaterial(`${baseKey}:highlight`, () => {
const m = child.material.clone();
m.emissive.setHex(0x111133);
return m;
});
}
});
}
@@ -390,9 +514,23 @@ export class HouseRenderer {
for (const hit of intersects) {
let obj = hit.object;
while (obj && !obj.userData.roomId) {
// Walk up to find furniture or room group
while (obj && !obj.userData.isFurniture && !obj.userData.roomId) {
obj = obj.parent;
}
if (obj && obj.userData.isFurniture) {
this.container.dispatchEvent(new CustomEvent('furnitureclick', {
detail: {
catalogId: obj.userData.catalogId,
roomId: obj.userData.roomId,
itemName: obj.userData.itemName,
wallMounted: obj.userData.wallMounted,
mesh: obj,
point: hit.point
}
}));
return;
}
if (obj && obj.userData.roomId) {
this.highlightRoom(obj.userData.roomId);
this.container.dispatchEvent(new CustomEvent('roomclick', {
@@ -426,7 +564,10 @@ export class HouseRenderer {
// Position in room-local coords, then offset by room position
const rx = room.position.x + placement.position.x;
const rz = room.position.y + placement.position.z;
mesh.position.set(rx, 0, rz);
// Wall-mounted items (mirrors, wall cabinets) use position.y for mount
// height offset; floor-standing items are placed at y=0
const ry = placement.wallMounted ? (placement.position.y ?? 0) : 0;
mesh.position.set(rx, ry, rz);
mesh.rotation.y = -(placement.rotation * Math.PI) / 180;
const key = `${roomDesign.roomId}-${placement.instanceId || placement.catalogId}-${i}`;
@@ -434,7 +575,9 @@ export class HouseRenderer {
isFurniture: true,
catalogId: placement.catalogId,
roomId: roomDesign.roomId,
itemName: catalogItem.name
furnitureIndex: i,
itemName: catalogItem.name,
wallMounted: !!placement.wallMounted
};
this.scene.add(mesh);
@@ -451,17 +594,17 @@ export class HouseRenderer {
for (const part of meshDef.parts) {
let geo;
if (part.geometry === 'box') {
geo = new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2]);
geo = this._getCachedGeometry(`box:${part.size[0]}:${part.size[1]}:${part.size[2]}`, () => new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2]));
} else if (part.geometry === 'cylinder') {
geo = new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16);
geo = this._getCachedGeometry(`cyl:${part.radius}:${part.height}`, () => new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16));
} else {
continue;
}
const mat = new THREE.MeshStandardMaterial({
const mat = this._getCachedMaterial(`furniture:${part.color}`, () => new THREE.MeshStandardMaterial({
color: new THREE.Color(part.color),
roughness: 0.7
});
}));
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(part.position[0], part.position[1], part.position[2]);

191
src/state.js Normal file
View File

@@ -0,0 +1,191 @@
/**
* DesignState — observable state with undo/redo for furniture design editing.
*
* All mutations go through methods that snapshot state for undo, then notify
* listeners so the renderer (and future UI panels) can react.
*/
export class DesignState {
constructor(initialDesign) {
this._state = structuredClone(initialDesign);
this._listeners = new Set();
this._undoStack = [];
this._redoStack = [];
this._maxUndo = 50;
// Index rooms by id for fast lookup
this._roomIndex = new Map();
this._rebuildIndex();
}
// ---- Read ----
/** Full design object (read-only reference — do not mutate directly). */
get design() {
return this._state;
}
/** Get room design entry by roomId, or undefined. */
getRoomDesign(roomId) {
return this._roomIndex.get(roomId);
}
/** Get a single furniture placement by roomId and index. */
getFurniture(roomId, index) {
const room = this._roomIndex.get(roomId);
return room?.furniture[index];
}
/** Get all furniture for a room. */
getRoomFurniture(roomId) {
const room = this._roomIndex.get(roomId);
return room?.furniture ?? [];
}
// ---- Write (all mutations go through these) ----
/** Generic update: merge `changes` into furniture at [roomId][index]. */
updateFurniture(roomId, index, changes) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
Object.assign(item, changes);
this._notify('furniture-update', { roomId, index });
}
/** Move furniture to a new position {x, z} (and optionally y). */
moveFurniture(roomId, index, newPosition) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
item.position = { ...item.position, ...newPosition };
this._notify('furniture-move', { roomId, index });
}
/** Set furniture rotation in degrees. */
rotateFurniture(roomId, index, degrees) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
item.rotation = degrees;
this._notify('furniture-rotate', { roomId, index });
}
/** Add a new furniture item to a room. Returns the new index. */
addFurniture(roomId, placement) {
this._pushUndo();
let room = this._roomIndex.get(roomId);
if (!room) {
// Create room entry if it doesn't exist yet
room = { roomId, furniture: [] };
this._state.rooms.push(room);
this._roomIndex.set(roomId, room);
}
const index = room.furniture.length;
room.furniture.push(structuredClone(placement));
this._notify('furniture-add', { roomId, index });
return index;
}
/** Remove furniture at [roomId][index]. Returns the removed item. */
removeFurniture(roomId, index) {
const room = this._roomIndex.get(roomId);
if (!room || index < 0 || index >= room.furniture.length) return null;
this._pushUndo();
const [removed] = room.furniture.splice(index, 1);
this._notify('furniture-remove', { roomId, index });
return removed;
}
/** Replace the entire design (e.g. when loading a saved file). */
loadDesign(newDesign) {
this._pushUndo();
this._state = structuredClone(newDesign);
this._rebuildIndex();
this._notify('design-load', {});
}
// ---- Undo / Redo ----
get canUndo() {
return this._undoStack.length > 0;
}
get canRedo() {
return this._redoStack.length > 0;
}
undo() {
if (!this.canUndo) return false;
this._redoStack.push(structuredClone(this._state));
this._state = this._undoStack.pop();
this._rebuildIndex();
this._notify('undo', {});
return true;
}
redo() {
if (!this.canRedo) return false;
this._undoStack.push(structuredClone(this._state));
this._state = this._redoStack.pop();
this._rebuildIndex();
this._notify('redo', {});
return true;
}
// ---- Observers ----
/**
* Register a change listener. Called with (type, detail) on every mutation.
* Returns an unsubscribe function.
*
* Event types:
* furniture-update, furniture-move, furniture-rotate,
* furniture-add, furniture-remove, design-load, undo, redo
*/
onChange(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
// ---- Serialization ----
/** Export current state as a plain JSON-serializable object. */
toJSON() {
return structuredClone(this._state);
}
// ---- Internal ----
_notify(type, detail) {
for (const fn of this._listeners) {
try {
fn(type, detail);
} catch (err) {
console.error('DesignState listener error:', err);
}
}
}
_pushUndo() {
this._undoStack.push(structuredClone(this._state));
if (this._undoStack.length > this._maxUndo) {
this._undoStack.shift();
}
// Any new mutation clears the redo stack
this._redoStack = [];
}
_rebuildIndex() {
this._roomIndex.clear();
if (this._state?.rooms) {
for (const room of this._state.rooms) {
this._roomIndex.set(room.roomId, room);
}
}
}
_getFurnitureOrThrow(roomId, index) {
const room = this._roomIndex.get(roomId);
if (!room) throw new Error(`Room not found: ${roomId}`);
const item = room.furniture[index];
if (!item) throw new Error(`Furniture not found: ${roomId}[${index}]`);
return item;
}
}

124
src/themes.js Normal file
View File

@@ -0,0 +1,124 @@
import { COLORS } from './renderer.js';
const THEMES = {
default: {
name: 'Standard',
swatch: '#e8e0d4',
colors: {
wall: { exterior: 0xe8e0d4, interior: 0xf5f0eb },
floor: { tile: 0xc8beb0, hardwood: 0xb5894e },
ceiling: 0xfaf8f5,
door: 0x8b6914,
window: 0x87ceeb,
windowFrame: 0xd0d0d0,
grid: 0xcccccc,
selected: 0x4a90d9
},
scene: { background: 0xf0f0f0, ambientIntensity: 0.6, directionalIntensity: 0.8 }
},
modern: {
name: 'Modern',
swatch: '#f5f5f5',
colors: {
wall: { exterior: 0xf5f5f5, interior: 0xffffff },
floor: { tile: 0xe0e0e0, hardwood: 0xc4a882 },
ceiling: 0xffffff,
door: 0x333333,
window: 0xa8d4f0,
windowFrame: 0x666666,
grid: 0xe0e0e0,
selected: 0x2196f3
},
scene: { background: 0xfafafa, ambientIntensity: 0.7, directionalIntensity: 0.6 }
},
warm: {
name: 'Warm',
swatch: '#ddd0b8',
colors: {
wall: { exterior: 0xddd0b8, interior: 0xf0e8d8 },
floor: { tile: 0xb8a890, hardwood: 0x9b6b3a },
ceiling: 0xf5efe5,
door: 0x6b4423,
window: 0x8bc4e0,
windowFrame: 0x8b7355,
grid: 0xc8b8a0,
selected: 0xd48b2c
},
scene: { background: 0xf5efe5, ambientIntensity: 0.5, directionalIntensity: 0.9 }
},
dark: {
name: 'Dark',
swatch: '#3a3a3a',
colors: {
wall: { exterior: 0x3a3a3a, interior: 0x4a4a4a },
floor: { tile: 0x2a2a2a, hardwood: 0x5a4030 },
ceiling: 0x333333,
door: 0x5a4030,
window: 0x4080b0,
windowFrame: 0x555555,
grid: 0x444444,
selected: 0x64b5f6
},
scene: { background: 0x222222, ambientIntensity: 0.4, directionalIntensity: 1.0 }
},
scandinavian: {
name: 'Scandi',
swatch: '#f0ece4',
colors: {
wall: { exterior: 0xf0ece4, interior: 0xfaf6f0 },
floor: { tile: 0xe8ddd0, hardwood: 0xd4b88c },
ceiling: 0xffffff,
door: 0xc4a87a,
window: 0xc0ddf0,
windowFrame: 0xb0b0b0,
grid: 0xd8d8d8,
selected: 0x5b9bd5
},
scene: { background: 0xf8f6f2, ambientIntensity: 0.65, directionalIntensity: 0.7 }
}
};
/**
* ThemeManager — applies visual themes by mutating COLORS and re-rendering.
*/
export class ThemeManager {
constructor(renderer) {
this.renderer = renderer;
this.currentTheme = 'default';
}
applyTheme(themeId) {
const theme = THEMES[themeId];
if (!theme) return;
this.currentTheme = themeId;
// Mutate the shared COLORS object
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;
COLORS.grid = theme.colors.grid;
COLORS.selected = theme.colors.selected;
// Update scene background and lights
this.renderer.scene.background.setHex(theme.scene.background);
this.renderer.scene.traverse(child => {
if (child.isAmbientLight) child.intensity = theme.scene.ambientIntensity;
if (child.isDirectionalLight) child.intensity = theme.scene.directionalIntensity;
});
// Clear cached materials/geometry and re-render to pick up new colors
this.renderer._clearFloor();
this.renderer.showFloor(this.renderer.currentFloor);
}
getThemes() {
return Object.entries(THEMES).map(([id, t]) => ({
id,
name: t.name,
swatch: t.swatch
}));
}
}