- RESEARCH.md: Tech stack analysis (Three.js, SVG, hybrid approaches) - DESIGN-interactive-features.md: Phase 2 design with 5 sprints, 14 tasks
986 lines
30 KiB
Markdown
986 lines
30 KiB
Markdown
# 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
|