Compare commits
10 Commits
a2b5211d30
...
ab3e8fd03c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3e8fd03c | ||
|
|
32eaf70635 | ||
|
|
e10abf4cf3 | ||
|
|
4d4d5f947b | ||
|
|
08248c6cad | ||
|
|
c0368f9f01 | ||
|
|
d0d9deb03a | ||
|
|
36bc0aedd7 | ||
|
|
35300aa57a | ||
|
|
bf4eee8595 |
985
DESIGN-interactive-features.md
Normal file
985
DESIGN-interactive-features.md
Normal 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
256
RESEARCH.md
Normal 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
233
src/catalog.js
Normal 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
124
src/export.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/index.html
278
src/index.html
@@ -52,10 +52,32 @@
|
|||||||
.room-item.active { background: #4a90d9; color: #fff; }
|
.room-item.active { background: #4a90d9; color: #fff; }
|
||||||
.room-item .area { font-size: 11px; opacity: 0.7; }
|
.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 {
|
#info {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
left: 16px;
|
left: 260px;
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0,0,0,0.7);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -63,20 +85,169 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
z-index: 10;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="viewer"></div>
|
<div id="viewer"></div>
|
||||||
|
|
||||||
|
<div id="catalog-panel"></div>
|
||||||
|
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
<h2 id="house-name">Loading...</h2>
|
<h2 id="house-name">Loading...</h2>
|
||||||
<h3>Floors</h3>
|
<h3>Floors</h3>
|
||||||
<div id="floor-buttons"></div>
|
<div id="floor-buttons"></div>
|
||||||
<h3>Rooms</h3>
|
<h3>Rooms</h3>
|
||||||
<div id="room-list"></div>
|
<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>
|
||||||
|
|
||||||
<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">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
@@ -88,33 +259,71 @@
|
|||||||
</script>
|
</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { HouseRenderer } from './renderer.js';
|
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 viewer = document.getElementById('viewer');
|
||||||
const renderer = new HouseRenderer(viewer);
|
const houseRenderer = new HouseRenderer(viewer);
|
||||||
|
|
||||||
let selectedRoom = null;
|
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;
|
document.getElementById('house-name').textContent = house.name;
|
||||||
await renderer.loadCatalog('../data/furniture-catalog.json');
|
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
|
||||||
await renderer.loadDesign('../designs/sample-house-design.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();
|
buildFloorButtons();
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
|
buildThemeButtons();
|
||||||
|
wireExportButtons();
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('house-name').textContent = 'Error loading data';
|
||||||
|
document.getElementById('info').textContent = err.message;
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildFloorButtons() {
|
function buildFloorButtons() {
|
||||||
const container = document.getElementById('floor-buttons');
|
const container = document.getElementById('floor-buttons');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const floor of renderer.getFloors()) {
|
for (const floor of houseRenderer.getFloors()) {
|
||||||
const btn = document.createElement('button');
|
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.textContent = floor.name;
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
renderer.showFloor(floor.index);
|
houseRenderer.showFloor(floor.index);
|
||||||
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
selectedRoom = null;
|
selectedRoom = null;
|
||||||
|
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -123,7 +332,7 @@
|
|||||||
function buildRoomList() {
|
function buildRoomList() {
|
||||||
const container = document.getElementById('room-list');
|
const container = document.getElementById('room-list');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const room of renderer.getRooms()) {
|
for (const room of houseRenderer.getRooms()) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'room-item';
|
item.className = 'room-item';
|
||||||
item.dataset.roomId = room.id;
|
item.dataset.roomId = room.id;
|
||||||
@@ -135,11 +344,12 @@
|
|||||||
|
|
||||||
function selectRoom(roomId) {
|
function selectRoom(roomId) {
|
||||||
selectedRoom = roomId;
|
selectedRoom = roomId;
|
||||||
renderer.focusRoom(roomId);
|
houseRenderer.focusRoom(roomId);
|
||||||
document.querySelectorAll('.room-item').forEach(el => {
|
document.querySelectorAll('.room-item').forEach(el => {
|
||||||
el.classList.toggle('active', el.dataset.roomId === roomId);
|
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) {
|
if (room) {
|
||||||
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||||
}
|
}
|
||||||
@@ -148,6 +358,50 @@
|
|||||||
viewer.addEventListener('roomclick', (e) => {
|
viewer.addEventListener('roomclick', (e) => {
|
||||||
selectRoom(e.detail.roomId);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
474
src/interaction.js
Normal file
474
src/interaction.js
Normal 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));
|
||||||
|
}
|
||||||
229
src/renderer.js
229
src/renderer.js
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
const COLORS = {
|
export const COLORS = {
|
||||||
wall: {
|
wall: {
|
||||||
exterior: 0xe8e0d4,
|
exterior: 0xe8e0d4,
|
||||||
interior: 0xf5f0eb
|
interior: 0xf5f0eb
|
||||||
@@ -28,6 +28,8 @@ export class HouseRenderer {
|
|||||||
this.roomMeshes = new Map();
|
this.roomMeshes = new Map();
|
||||||
this.roomLabels = new Map();
|
this.roomLabels = new Map();
|
||||||
this.furnitureMeshes = new Map();
|
this.furnitureMeshes = new Map();
|
||||||
|
this._materialCache = new Map();
|
||||||
|
this._geometryCache = new Map();
|
||||||
|
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.scene.background = new THREE.Color(0xf0f0f0);
|
this.scene.background = new THREE.Color(0xf0f0f0);
|
||||||
@@ -41,7 +43,7 @@ export class HouseRenderer {
|
|||||||
this.camera.position.set(6, 12, 14);
|
this.camera.position.set(6, 12, 14);
|
||||||
this.camera.lookAt(6, 0, 5);
|
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.setSize(container.clientWidth, container.clientHeight);
|
||||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
this.renderer.shadowMap.enabled = true;
|
this.renderer.shadowMap.enabled = true;
|
||||||
@@ -74,6 +76,12 @@ export class HouseRenderer {
|
|||||||
dir.castShadow = true;
|
dir.castShadow = true;
|
||||||
dir.shadow.mapSize.width = 2048;
|
dir.shadow.mapSize.width = 2048;
|
||||||
dir.shadow.mapSize.height = 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);
|
this.scene.add(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,42 +92,56 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadHouse(url) {
|
async loadHouse(url) {
|
||||||
|
try {
|
||||||
const res = await fetch(url);
|
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.houseData = await res.json();
|
||||||
this.showFloor(0);
|
this.showFloor(0);
|
||||||
return this.houseData;
|
return this.houseData;
|
||||||
|
} catch (err) {
|
||||||
|
this._emitError('loadHouse', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCatalog(url) {
|
async loadCatalog(url) {
|
||||||
|
try {
|
||||||
const res = await fetch(url);
|
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.catalogData = await res.json();
|
||||||
this._catalogIndex = new Map();
|
this._catalogIndex = new Map();
|
||||||
for (const item of this.catalogData.items) {
|
for (const item of this.catalogData.items) {
|
||||||
this._catalogIndex.set(item.id, item);
|
this._catalogIndex.set(item.id, item);
|
||||||
}
|
}
|
||||||
return this.catalogData;
|
return this.catalogData;
|
||||||
|
} catch (err) {
|
||||||
|
this._emitError('loadCatalog', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadDesign(url) {
|
async loadDesign(url) {
|
||||||
|
try {
|
||||||
const res = await fetch(url);
|
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.designData = await res.json();
|
||||||
this._placeFurnitureForFloor();
|
this._placeFurnitureForFloor();
|
||||||
return this.designData;
|
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) {
|
showFloor(index) {
|
||||||
this.currentFloor = index;
|
this.currentFloor = index;
|
||||||
// Clear existing room meshes
|
this._clearFloor();
|
||||||
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();
|
|
||||||
|
|
||||||
const floor = this.houseData.floors[index];
|
const floor = this.houseData.floors[index];
|
||||||
if (!floor) return;
|
if (!floor) return;
|
||||||
@@ -129,6 +151,46 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._placeFurnitureForFloor();
|
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) {
|
_renderRoom(room, ceilingHeight) {
|
||||||
@@ -160,16 +222,67 @@ export class HouseRenderer {
|
|||||||
this._addWall(group, wd, wallData, ceilingHeight);
|
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.scene.add(group);
|
||||||
this.roomMeshes.set(room.id, 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) {
|
_addFloor(group, width, length, flooring) {
|
||||||
const geo = new THREE.PlaneGeometry(width, length);
|
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
const color = COLORS.floor[flooring] || COLORS.floor.hardwood;
|
||||||
color: COLORS.floor[flooring] || COLORS.floor.hardwood,
|
const mat = this._getCachedMaterial(`floor:${color}`, () => new THREE.MeshStandardMaterial({ color, roughness: 0.8 }));
|
||||||
roughness: 0.8
|
|
||||||
});
|
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
mesh.position.set(width / 2, 0, length / 2);
|
mesh.position.set(width / 2, 0, length / 2);
|
||||||
@@ -178,13 +291,13 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_addCeiling(group, width, length, height) {
|
_addCeiling(group, width, length, height) {
|
||||||
const geo = new THREE.PlaneGeometry(width, length);
|
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
const mat = this._getCachedMaterial('ceiling', () => new THREE.MeshStandardMaterial({
|
||||||
color: COLORS.ceiling,
|
color: COLORS.ceiling,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
side: THREE.DoubleSide
|
side: THREE.DoubleSide
|
||||||
});
|
}));
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
mesh.rotation.x = Math.PI / 2;
|
mesh.rotation.x = Math.PI / 2;
|
||||||
mesh.position.set(width / 2, height, length / 2);
|
mesh.position.set(width / 2, height, length / 2);
|
||||||
@@ -202,13 +315,15 @@ export class HouseRenderer {
|
|||||||
const wallWidth = wallDef.wallWidth;
|
const wallWidth = wallDef.wallWidth;
|
||||||
const segments = this._computeWallSegments(openings, wallWidth, ceilingHeight);
|
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) {
|
for (const seg of segments) {
|
||||||
const geo = new THREE.BoxGeometry(seg.w, seg.h, thickness);
|
const geo = this._getCachedGeometry(`box:${seg.w}:${seg.h}:${thickness}`, () => new THREE.BoxGeometry(seg.w, seg.h, thickness));
|
||||||
const mat = new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 });
|
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
mesh.receiveShadow = 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) {
|
if (wallDef.horizontal) {
|
||||||
mesh.position.set(seg.cx, seg.cy, wallDef.pos);
|
mesh.position.set(seg.cx, seg.cy, wallDef.pos);
|
||||||
@@ -274,8 +389,9 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_addDoorMesh(group, wallDef, door, thickness) {
|
_addDoorMesh(group, wallDef, door, thickness) {
|
||||||
const geo = new THREE.BoxGeometry(door.width, door.height, thickness * 0.5);
|
const t = thickness * 0.5;
|
||||||
const mat = new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 });
|
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 mesh = new THREE.Mesh(geo, mat);
|
||||||
|
|
||||||
const cx = door.position + door.width / 2;
|
const cx = door.position + door.width / 2;
|
||||||
@@ -292,13 +408,14 @@ export class HouseRenderer {
|
|||||||
|
|
||||||
_addWindowMesh(group, wallDef, win, thickness) {
|
_addWindowMesh(group, wallDef, win, thickness) {
|
||||||
// Glass pane
|
// Glass pane
|
||||||
const geo = new THREE.BoxGeometry(win.width, win.height, thickness * 0.3);
|
const t = thickness * 0.3;
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
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,
|
color: COLORS.window,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
roughness: 0.1
|
roughness: 0.1
|
||||||
});
|
}));
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
|
||||||
const cx = win.position + win.width / 2;
|
const cx = win.position + win.width / 2;
|
||||||
@@ -313,8 +430,8 @@ export class HouseRenderer {
|
|||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
|
|
||||||
// Window frame
|
// Window frame
|
||||||
const frameGeo = new THREE.EdgesGeometry(geo);
|
const frameGeo = this._getCachedGeometry(`edges:${win.width}:${win.height}:${t}`, () => new THREE.EdgesGeometry(geo));
|
||||||
const frameMat = new THREE.LineBasicMaterial({ color: COLORS.windowFrame });
|
const frameMat = this._getCachedMaterial('windowFrame', () => new THREE.LineBasicMaterial({ color: COLORS.windowFrame }));
|
||||||
const frame = new THREE.LineSegments(frameGeo, frameMat);
|
const frame = new THREE.LineSegments(frameGeo, frameMat);
|
||||||
frame.position.copy(mesh.position);
|
frame.position.copy(mesh.position);
|
||||||
frame.rotation.copy(mesh.rotation);
|
frame.rotation.copy(mesh.rotation);
|
||||||
@@ -322,21 +439,28 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
highlightRoom(roomId) {
|
highlightRoom(roomId) {
|
||||||
// Reset all rooms
|
// Reset all rooms - restore original shared materials
|
||||||
for (const [id, group] of this.roomMeshes) {
|
for (const [id, group] of this.roomMeshes) {
|
||||||
group.traverse(child => {
|
group.traverse(child => {
|
||||||
if (child.isMesh && child.material && child.userData.isWall) {
|
if (child.isMesh && child.userData.isWall && child.userData._origMaterial) {
|
||||||
child.material.emissive?.setHex(0x000000);
|
child.material = child.userData._origMaterial;
|
||||||
|
delete child.userData._origMaterial;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight target
|
// Highlight target - swap to cached highlight variant
|
||||||
const target = this.roomMeshes.get(roomId);
|
const target = this.roomMeshes.get(roomId);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.traverse(child => {
|
target.traverse(child => {
|
||||||
if (child.isMesh && child.userData.isWall) {
|
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) {
|
for (const hit of intersects) {
|
||||||
let obj = hit.object;
|
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;
|
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) {
|
if (obj && obj.userData.roomId) {
|
||||||
this.highlightRoom(obj.userData.roomId);
|
this.highlightRoom(obj.userData.roomId);
|
||||||
this.container.dispatchEvent(new CustomEvent('roomclick', {
|
this.container.dispatchEvent(new CustomEvent('roomclick', {
|
||||||
@@ -426,7 +564,10 @@ export class HouseRenderer {
|
|||||||
// Position in room-local coords, then offset by room position
|
// Position in room-local coords, then offset by room position
|
||||||
const rx = room.position.x + placement.position.x;
|
const rx = room.position.x + placement.position.x;
|
||||||
const rz = room.position.y + placement.position.z;
|
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;
|
mesh.rotation.y = -(placement.rotation * Math.PI) / 180;
|
||||||
|
|
||||||
const key = `${roomDesign.roomId}-${placement.instanceId || placement.catalogId}-${i}`;
|
const key = `${roomDesign.roomId}-${placement.instanceId || placement.catalogId}-${i}`;
|
||||||
@@ -434,7 +575,9 @@ export class HouseRenderer {
|
|||||||
isFurniture: true,
|
isFurniture: true,
|
||||||
catalogId: placement.catalogId,
|
catalogId: placement.catalogId,
|
||||||
roomId: roomDesign.roomId,
|
roomId: roomDesign.roomId,
|
||||||
itemName: catalogItem.name
|
furnitureIndex: i,
|
||||||
|
itemName: catalogItem.name,
|
||||||
|
wallMounted: !!placement.wallMounted
|
||||||
};
|
};
|
||||||
|
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
@@ -451,17 +594,17 @@ export class HouseRenderer {
|
|||||||
for (const part of meshDef.parts) {
|
for (const part of meshDef.parts) {
|
||||||
let geo;
|
let geo;
|
||||||
if (part.geometry === 'box') {
|
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') {
|
} 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 {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
const mat = this._getCachedMaterial(`furniture:${part.color}`, () => new THREE.MeshStandardMaterial({
|
||||||
color: new THREE.Color(part.color),
|
color: new THREE.Color(part.color),
|
||||||
roughness: 0.7
|
roughness: 0.7
|
||||||
});
|
}));
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
mesh.position.set(part.position[0], part.position[1], part.position[2]);
|
mesh.position.set(part.position[0], part.position[1], part.position[2]);
|
||||||
|
|||||||
191
src/state.js
Normal file
191
src/state.js
Normal 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
124
src/themes.js
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user