Compare commits
20 Commits
a2b5211d30
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e498818a7 | ||
|
|
d53686ed65 | ||
|
|
53999728c4 | ||
|
|
8ac5b3f1f9 | ||
|
|
bc94d41f2b | ||
|
|
4ca495209d | ||
|
|
ceea42ac1d | ||
|
|
cf0fe586eb | ||
|
|
53ee0fc1ec | ||
|
|
d35b61648e | ||
|
|
ab3e8fd03c | ||
|
|
32eaf70635 | ||
|
|
e10abf4cf3 | ||
|
|
4d4d5f947b | ||
|
|
08248c6cad | ||
|
|
c0368f9f01 | ||
|
|
d0d9deb03a | ||
|
|
36bc0aedd7 | ||
|
|
35300aa57a | ||
|
|
bf4eee8595 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
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
|
||||||
352
RESEARCH-ikea-data.md
Normal file
352
RESEARCH-ikea-data.md
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
# IKEA Furniture Data Import — Research Report
|
||||||
|
|
||||||
|
**Task:** t-05dd7
|
||||||
|
**Date:** 2026-02-07
|
||||||
|
**Researcher:** researcher role
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**There is no official public IKEA API for furniture data.** However, there are multiple viable paths to get IKEA product dimensions and 3D models into our room planner. The recommended approach is a **tiered strategy**: start with a curated hand-built catalog of common IKEA items using known dimensions, with optional GLB model loading for enhanced visuals later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. IKEA APIs (Official)
|
||||||
|
|
||||||
|
### IKEA Has No Public Developer API
|
||||||
|
|
||||||
|
IKEA does not offer a public REST/GraphQL API for developers. There is no developer portal, no API keys, no official documentation.
|
||||||
|
|
||||||
|
### Undocumented Internal Endpoints
|
||||||
|
|
||||||
|
IKEA's website uses internal APIs that have been partially reverse-engineered:
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Format |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| `sik.search.blue.cdtapps.com/{country}/{lang}/search-result-page?q={query}&size=24&types=PRODUCT` | Product search | JSON |
|
||||||
|
| `www.ikea.com/{country}/{lang}/products/{itemNo[5:]}/{itemNo}.json` | Product detail (PIP) | JSON |
|
||||||
|
| `api.ingka.ikea.com/cia/availabilities/{country}/{lang}?itemNos={itemNo}` | Stock/availability | JSON |
|
||||||
|
| `www.ikea.com/{region}/{country}/iows/catalog/availability/{itemNo}` | Legacy availability (deprecated Dec 2021) | XML |
|
||||||
|
| `web-api.ikea.com/{country}/{lang}/rotera/data/exists/{itemNo}/` | 3D model existence check | JSON |
|
||||||
|
| `web-api.ikea.com/{country}/{lang}/rotera/data/model/{itemNo}/` | 3D model metadata + CDN URL | JSON |
|
||||||
|
|
||||||
|
The **Rotera API** is most relevant for us — it returns a `modelUrl` field pointing to the actual GLB file on IKEA's CDN. This is what the Blender IKEA Browser add-on uses internally.
|
||||||
|
|
||||||
|
**IKEA Kreativ**: Their AI-powered room planner at `ikea.com/us/en/home-design/` uses AWS infrastructure (API Gateway at `ddh79d7xh5.execute-api.us-east-1.amazonaws.com`). No public API.
|
||||||
|
|
||||||
|
**Status**: All endpoints are undocumented, unsupported, and may break at any time. Rate limiting and anti-bot measures exist. The IOWS endpoints are deprecated.
|
||||||
|
|
||||||
|
**Sources**: https://sbmueller.github.io/posts/ikea/ | Postman workspace: https://www.postman.com/galactic-shuttle-566922/ikea/overview
|
||||||
|
|
||||||
|
### Archived Python Client
|
||||||
|
|
||||||
|
**vrslev/ikea-api-client** (GitHub, 1k+ stars) — Python client for IKEA internal APIs supporting cart, search, stock, item info, and 3D models. **Archived October 2024** — no longer maintained. MIT license.
|
||||||
|
|
||||||
|
- Supported: stock lookup, product search, item specs, 3D model retrieval (`RoteraItem` endpoint)
|
||||||
|
- Install: `pip install ikea-api[httpx]`
|
||||||
|
- **Not recommended** for new projects since archived
|
||||||
|
|
||||||
|
**Source**: https://github.com/vrslev/ikea-api-client
|
||||||
|
|
||||||
|
### npm: ikea-availability-checker (Active)
|
||||||
|
|
||||||
|
**Ephigenia/ikea-availability-checker** — Node.js package for checking IKEA stock across 40+ countries and 400+ stores. Actively maintained.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ikea-availability-checker
|
||||||
|
# CLI: ikea-availability-checker stock --country us 30457903
|
||||||
|
```
|
||||||
|
|
||||||
|
Not directly useful for dimensions/models but proves Node.js access to IKEA endpoints is feasible.
|
||||||
|
|
||||||
|
**Source**: https://github.com/Ephigenia/ikea-availability-checker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Open Datasets with Dimensions
|
||||||
|
|
||||||
|
### Best: Hugging Face — IKEA US CommerceTXT (30,511 products)
|
||||||
|
|
||||||
|
- **Records**: 30,511 products across 632 categories
|
||||||
|
- **Fields**: Name, SKU, Price, Dimensions (Width/Height/Depth), Materials, Category, Images, URLs
|
||||||
|
- **Format**: CommerceTXT v1.0.1 (text-based, parseable) + Parquet (56.5 MB)
|
||||||
|
- **License**: CC0 1.0 (Public Domain) — fully usable
|
||||||
|
- **Date**: July 15, 2025
|
||||||
|
- **Dimensions**: Yes, in the `@SPECS` section (e.g., `Width: 12", Height: 16"`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datasets import load_dataset
|
||||||
|
dataset = load_dataset("tsazan/ikea-us-commercetxt")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source**: https://huggingface.co/datasets/tsazan/ikea-us-commercetxt
|
||||||
|
|
||||||
|
### Good: Kaggle — IKEA SA Furniture (2,962 items)
|
||||||
|
|
||||||
|
- **Records**: 2,962 items
|
||||||
|
- **Fields**: name, category, price, width (cm), height (cm), depth (cm), designer, description
|
||||||
|
- **Format**: CSV
|
||||||
|
- **License**: Kaggle dataset terms
|
||||||
|
- **Date**: April 2020 (somewhat dated)
|
||||||
|
- **Dimensions**: Yes — width, height, depth in centimeters
|
||||||
|
|
||||||
|
**Source**: https://www.kaggle.com/datasets/ahmedkallam/ikea-sa-furniture-web-scraping
|
||||||
|
|
||||||
|
### Good: GitHub — IKEA Dataset (12,600+ items)
|
||||||
|
|
||||||
|
- **Records**: 12,600+ images with furniture dimensions
|
||||||
|
- **Fields**: Name, dimensions (width/length/height cm), material
|
||||||
|
- **Format**: ZIP archives + Python scraping script
|
||||||
|
- **Organization**: By room (bedroom, bathroom, kitchen, living room, etc.)
|
||||||
|
- **Date**: January 2020
|
||||||
|
|
||||||
|
**Source**: https://github.com/valexande/IKEA-Dataset
|
||||||
|
|
||||||
|
### Reference: Dimensions.com — IKEA Collection
|
||||||
|
|
||||||
|
- **Content**: Dozens of IKEA products with precise dimensions + technical drawings
|
||||||
|
- **Formats**: DWG, SVG, JPG (2D) + 3DM, OBJ, SKP (3D models)
|
||||||
|
- **Coverage**: KALLAX, HEMNES, BESTÅ, BEKANT, and many more series
|
||||||
|
- **Dual units**: Both inches and centimeters
|
||||||
|
- **Includes**: Weight capacities
|
||||||
|
- **Note**: Good for manual reference but not easily machine-parseable without scraping
|
||||||
|
|
||||||
|
**Source**: https://www.dimensions.com/collection/ikea-furniture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. IKEA 3D Models
|
||||||
|
|
||||||
|
### Web-Sourced GLB Models (Best Path for Three.js)
|
||||||
|
|
||||||
|
IKEA product pages with a "View in 3D" button serve simplified, Draco-compressed GLB (binary glTF) files. These are:
|
||||||
|
- **Format**: GLB (binary glTF 2.0) — native Three.js format
|
||||||
|
- **Quality**: "Lowest quality versions" — simplified from manufacturing models for web delivery
|
||||||
|
- **Properties**: Scaled in metric, basic materials applied, KHR_Texture_Transform extension
|
||||||
|
- **Compatibility**: Direct import into Three.js via `GLTFLoader` + `DRACOLoader`
|
||||||
|
|
||||||
|
#### Tools to Download IKEA GLB Models
|
||||||
|
|
||||||
|
| Tool | Type | Stars | Status | License |
|
||||||
|
|------|------|-------|--------|---------|
|
||||||
|
| [IKEA 3D Model Download Button](https://github.com/apinanaivot/IKEA-3D-Model-Download-Button) | Tampermonkey userscript | 941 | Active (Jan 2026) | — |
|
||||||
|
| [IKEA 3D Model Batch Downloader](https://github.com/apinanaivot/IKEA-3d-model-batch-downloader) | Python (Selenium+BS4) | — | Active | GPL-3.0 |
|
||||||
|
| [IKEA Browser for Blender](https://extensions.blender.org/add-ons/ikea-browser/) | Blender addon | 95k downloads | Active (v0.4.0, Oct 2025) | GPL-3.0 |
|
||||||
|
| [Chrome Extension](https://chromewebstore.google.com/detail/ikea-3d-models-downloader/kihdhjecfjagoplfnnpdbbnhkjhnlfeg) | Chrome extension | — | Active | — |
|
||||||
|
|
||||||
|
The **batch downloader** workflow:
|
||||||
|
1. Give it an IKEA category URL (e.g., `https://www.ikea.com/fi/fi/cat/chairs-fu002/`)
|
||||||
|
2. Scrapes product links → extracts 3D model URLs → downloads GLB files
|
||||||
|
3. Stores metadata in SQLite database
|
||||||
|
4. Deduplication built-in
|
||||||
|
|
||||||
|
**Note**: Not all IKEA products have 3D models. Only items with the "View in 3D" button are available.
|
||||||
|
|
||||||
|
### Official IKEA 3D Assembly Dataset (GitHub)
|
||||||
|
|
||||||
|
- **Source**: https://github.com/IKEA/IKEA3DAssemblyDataset
|
||||||
|
- **Items**: Only 5 products (LACK, EKET, BEKVÄM, DALFRED)
|
||||||
|
- **Formats**: GLB/glTF + OBJ + PDF assembly instructions
|
||||||
|
- **License**: CC BY-NC-SA 4.0 — **non-commercial only, research purposes**
|
||||||
|
- **Verdict**: Too few items and restrictive license. Not suitable for our use.
|
||||||
|
|
||||||
|
### Sweet Home 3D IKEA Libraries
|
||||||
|
|
||||||
|
- **180 models**: https://3deshop.blogscopia.com/180-ikea-models-for-sweethome3d/
|
||||||
|
- **342 models (bundle)**: https://3deshop.blogscopia.com/ikea-bundle-342-models/
|
||||||
|
- **Format**: SH3F (Sweet Home 3D format) — would need conversion
|
||||||
|
- **Quality**: Community-created, based on real IKEA products
|
||||||
|
- **Note**: Format is proprietary to Sweet Home 3D, would require extraction/conversion
|
||||||
|
|
||||||
|
### Third-Party Model Sources
|
||||||
|
|
||||||
|
- **Sketchfab**: Search "IKEA" — many free community models, various licenses
|
||||||
|
- **TurboSquid**: Free IKEA models available
|
||||||
|
- **Clara.io**: Some IKEA models
|
||||||
|
- **Trimble 3D Warehouse**: IKEA models available (SketchUp format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Web Scraping Feasibility
|
||||||
|
|
||||||
|
### Existing Scrapers
|
||||||
|
|
||||||
|
| Project | Stack | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| [IKEA Scraper](https://github.com/Abdelrahman-Hekal/IKEA_Scraper) | Python | Full scraper for ikea.com |
|
||||||
|
| [ikea-webscraper](https://github.com/gamladz/ikea-webscraper) | Selenium | Product info extraction |
|
||||||
|
| [IKEA-project](https://github.com/furkansenn/IKEA-project) | Selenium | Product scraping |
|
||||||
|
| [ikea-scraper](https://github.com/bonzi/ikea-scraper) | — | IKEA data extraction |
|
||||||
|
|
||||||
|
### Technical Challenges
|
||||||
|
|
||||||
|
- IKEA uses heavy client-side rendering (React/Next.js) requiring browser automation (Selenium)
|
||||||
|
- Anti-bot protections: CAPTCHAs, rate limiting, IP blocking
|
||||||
|
- Dimension data is embedded in product pages in varying formats
|
||||||
|
- Product IDs are 8-digit numbers but URL structure varies by locale
|
||||||
|
|
||||||
|
### Legal Considerations
|
||||||
|
|
||||||
|
- IKEA's Terms of Service prohibit automated scraping
|
||||||
|
- IKEA actively protects trademarks and trade dress under US Trademark Act
|
||||||
|
- For personal/non-commercial home planning use, tools like the Blender extension operate in a gray area
|
||||||
|
- The HuggingFace CommerceTXT dataset (CC0) is the safest legal path for product data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Standard IKEA Dimensions Reference
|
||||||
|
|
||||||
|
Manually verified dimensions for the most common IKEA product lines:
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
| Product | Width (cm) | Depth (cm) | Height (cm) |
|
||||||
|
|---------|-----------|-----------|------------|
|
||||||
|
| KALLAX 1×4 | 42 | 39 | 147 |
|
||||||
|
| KALLAX 2×2 | 77 | 39 | 77 |
|
||||||
|
| KALLAX 2×4 | 77 | 39 | 147 |
|
||||||
|
| KALLAX 4×4 | 147 | 39 | 147 |
|
||||||
|
| BILLY bookcase (standard) | 80 | 28 | 202 |
|
||||||
|
| BILLY bookcase (narrow) | 40 | 28 | 202 |
|
||||||
|
| BILLY bookcase (short) | 80 | 28 | 106 |
|
||||||
|
| HEMNES 6-drawer dresser | 108 | 50 | 131 |
|
||||||
|
| HEMNES 3-drawer dresser | 108 | 50 | 96 |
|
||||||
|
| HEMNES bookcase | 90 | 37 | 197 |
|
||||||
|
| BESTÅ TV bench | 120/180 | 42 | 38 |
|
||||||
|
| MALM 6-drawer dresser | 80 | 48 | 123 |
|
||||||
|
| MALM 4-drawer dresser | 80 | 48 | 100 |
|
||||||
|
| PAX wardrobe (standard) | 100/150/200 | 58 | 201/236 |
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
| Product | Width (cm) | Depth (cm) | Height (cm) |
|
||||||
|
|---------|-----------|-----------|------------|
|
||||||
|
| LACK side table | 55 | 55 | 45 |
|
||||||
|
| LACK coffee table | 90 | 55 | 45 |
|
||||||
|
| LACK TV bench | 90 | 26 | 45 |
|
||||||
|
| LISABO desk | 118 | 45 | 74 |
|
||||||
|
| BEKANT desk (rect) | 120/140/160 | 80 | 65-85 |
|
||||||
|
| LINNMON/ALEX desk | 150 | 75 | 73 |
|
||||||
|
| MELLTORP dining table | 125 | 75 | 74 |
|
||||||
|
| EKEDALEN ext. table | 120-180 | 80 | 75 |
|
||||||
|
|
||||||
|
### Seating
|
||||||
|
|
||||||
|
| Product | Width (cm) | Depth (cm) | Height (cm) |
|
||||||
|
|---------|-----------|-----------|------------|
|
||||||
|
| POÄNG armchair | 68 | 82 | 100 |
|
||||||
|
| STRANDMON wing chair | 82 | 96 | 101 |
|
||||||
|
| KLIPPAN 2-seat sofa | 180 | 88 | 66 |
|
||||||
|
| EKTORP 3-seat sofa | 218 | 88 | 88 |
|
||||||
|
| KIVIK 3-seat sofa | 228 | 95 | 83 |
|
||||||
|
| MARKUS office chair | 62 | 60 | 129-140 |
|
||||||
|
|
||||||
|
### Beds
|
||||||
|
|
||||||
|
| Product | Width (cm) | Depth (cm) | Height (cm) |
|
||||||
|
|---------|-----------|-----------|------------|
|
||||||
|
| MALM bed (queen, 160) | 160 | 209 | 92 (headboard) |
|
||||||
|
| MALM bed (king, 180) | 180 | 209 | 92 (headboard) |
|
||||||
|
| MALM bed (single, 90) | 90 | 209 | 92 (headboard) |
|
||||||
|
| HEMNES bed (queen) | 163 | 211 | 66 (headboard 112) |
|
||||||
|
| KURA reversible bed | 99 | 209 | 116 |
|
||||||
|
| SUNDVIK child bed | 80 | 167 | 83 |
|
||||||
|
|
||||||
|
### Kitchen
|
||||||
|
|
||||||
|
| Product | Standard Width (cm) | Depth (cm) | Height (cm) |
|
||||||
|
|---------|-----------|-----------|------------|
|
||||||
|
| METOD base cabinet | 60/80 | 60 | 80 |
|
||||||
|
| METOD wall cabinet | 60/80 | 37 | 60/80/100 |
|
||||||
|
| METOD tall cabinet | 60 | 60 | 200/220 |
|
||||||
|
| KNOXHULT base cabinet | 120/180 | 61 | 85 |
|
||||||
|
| VADHOLMA kitchen island | 126 | 79 | 90 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommendations for Our Project
|
||||||
|
|
||||||
|
### Recommended Approach: Tiered Strategy
|
||||||
|
|
||||||
|
#### Tier 1: Curated IKEA Catalog (Immediate)
|
||||||
|
|
||||||
|
Extend `data/furniture-catalog.json` with IKEA-specific items:
|
||||||
|
- Add 30-50 most popular IKEA products with verified dimensions
|
||||||
|
- Use our existing procedural mesh format (box geometry parts)
|
||||||
|
- Add `ikeaId` field (8-digit product number) for future linking
|
||||||
|
- Add `ikeaSeries` field (KALLAX, BILLY, MALM, etc.)
|
||||||
|
- Add `ikeaUrl` field for reference
|
||||||
|
|
||||||
|
**Schema extension**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x4",
|
||||||
|
"name": "KALLAX Regal 2×4",
|
||||||
|
"ikeaId": "80275887",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"ikeaUrl": "https://www.ikea.com/de/de/p/kallax-regal-weiss-80275887/",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.77, "depth": 0.39, "height": 1.47 },
|
||||||
|
"variants": [
|
||||||
|
{ "color": "white", "hex": "#ffffff" },
|
||||||
|
{ "color": "black-brown", "hex": "#3c3028" },
|
||||||
|
{ "color": "white-stained-oak", "hex": "#d4c4a8" }
|
||||||
|
],
|
||||||
|
"mesh": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: ~2-3 hours to add 30 items manually from IKEA website
|
||||||
|
**Risk**: None — uses verified public dimensions
|
||||||
|
|
||||||
|
#### Tier 2: GLB Model Import (Enhancement)
|
||||||
|
|
||||||
|
Add optional GLB model loading to the renderer:
|
||||||
|
- Use Three.js `GLTFLoader` + `DRACOLoader` to load IKEA GLB files
|
||||||
|
- Users download GLB files themselves (personal use) via browser extension
|
||||||
|
- Store in local `models/` directory
|
||||||
|
- Fall back to procedural mesh if GLB not available
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In catalog entry:
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x4",
|
||||||
|
"model3d": "models/ikea/kallax-2x4.glb", // optional
|
||||||
|
"mesh": { ... } // fallback procedural geometry
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: ~4-6 hours for GLTFLoader integration
|
||||||
|
**Risk**: Low — GLB loading is standard Three.js functionality
|
||||||
|
|
||||||
|
#### Tier 3: Dataset Import Tool (Future)
|
||||||
|
|
||||||
|
Build a converter that parses the HuggingFace CommerceTXT dataset:
|
||||||
|
- Extract product dimensions from `@SPECS` section
|
||||||
|
- Map categories to our catalog format
|
||||||
|
- Auto-generate procedural meshes from dimensions
|
||||||
|
- Run offline as a data pipeline
|
||||||
|
|
||||||
|
**Effort**: ~1-2 days
|
||||||
|
**Risk**: Medium — dimension parsing from free-text specs varies in reliability
|
||||||
|
|
||||||
|
### What NOT to Do
|
||||||
|
|
||||||
|
1. **Don't scrape IKEA live** — Legal risk, fragile, unnecessary when datasets exist
|
||||||
|
2. **Don't depend on undocumented APIs** — They change without notice
|
||||||
|
3. **Don't bundle IKEA 3D models** — Trademark/IP issues. Let users provide their own
|
||||||
|
4. **Don't use the IKEA 3D Assembly Dataset for the app** — License is NC-SA research only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Key Sources
|
||||||
|
|
||||||
|
| Resource | URL | Best For |
|
||||||
|
|----------|-----|----------|
|
||||||
|
| HuggingFace IKEA US | https://huggingface.co/datasets/tsazan/ikea-us-commercetxt | Dimension data (CC0) |
|
||||||
|
| Kaggle IKEA SA | https://www.kaggle.com/datasets/ahmedkallam/ikea-sa-furniture-web-scraping | Structured CSV with dims |
|
||||||
|
| Dimensions.com IKEA | https://www.dimensions.com/collection/ikea-furniture | Reference dimensions + drawings |
|
||||||
|
| 3D Batch Downloader | https://github.com/apinanaivot/IKEA-3d-model-batch-downloader | Downloading GLB models |
|
||||||
|
| Blender IKEA Browser | https://extensions.blender.org/add-ons/ikea-browser/ | Model inspection/conversion |
|
||||||
|
| IKEA API Blog | https://sbmueller.github.io/posts/ikea/ | Understanding IKEA endpoints |
|
||||||
|
| Sweet Home 3D IKEA | https://3deshop.blogscopia.com/ikea-bundle-342-models/ | Pre-made 3D models (needs conversion) |
|
||||||
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
|
||||||
213
bun.lock
Normal file
213
bun.lock
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "house-design",
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||||
|
|
||||||
|
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||||
|
|
||||||
|
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||||
|
|
||||||
|
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||||
|
|
||||||
|
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
||||||
|
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||||
|
|
||||||
|
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
|
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||||
|
|
||||||
|
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||||
|
|
||||||
|
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
292
data/apartment-small.json
Normal file
292
data/apartment-small.json
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
{
|
||||||
|
"name": "Stadtwohnung Kompakt",
|
||||||
|
"description": "Small city apartment, 1 floor, ~60sqm living space",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 9, "depth": 7 },
|
||||||
|
"wallThickness": 0.2,
|
||||||
|
"roofType": "flat"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Wohnung",
|
||||||
|
"nameEN": "Apartment",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.5,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur",
|
||||||
|
"name": "Flur",
|
||||||
|
"nameEN": "Hallway",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 3.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 1.5, "length": 7.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.2,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-kueche"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d3",
|
||||||
|
"type": "open",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-wohnzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d4",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-badezimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-kueche",
|
||||||
|
"name": "Küche",
|
||||||
|
"nameEN": "Kitchen",
|
||||||
|
"type": "kitchen",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 1.0,
|
||||||
|
"sillHeight": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer",
|
||||||
|
"nameEN": "Living Room",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 3.0 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 4.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 5.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Bedroom",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 5.0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 4.0, "length": 4.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
769
data/ikea-catalog.json
Normal file
769
data/ikea-catalog.json
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"source": "ikea",
|
||||||
|
"units": "meters",
|
||||||
|
"description": "Curated IKEA furniture catalog with verified dimensions",
|
||||||
|
"categories": [
|
||||||
|
"seating",
|
||||||
|
"tables",
|
||||||
|
"storage",
|
||||||
|
"beds",
|
||||||
|
"kitchen",
|
||||||
|
"office"
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-1x4",
|
||||||
|
"name": "KALLAX Shelf 1x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.42, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.42, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x2",
|
||||||
|
"name": "KALLAX Shelf 2x2",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.77, "depth": 0.39, "height": 0.77 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.77, 0.77, 0.39], "position": [0, 0.385, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV", "geometry": "box", "size": [0.02, 0.73, 0.37], "position": [0, 0.385, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.385, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x4",
|
||||||
|
"name": "KALLAX Shelf 2x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.77, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.77, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH1", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH2", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH3", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-4x4",
|
||||||
|
"name": "KALLAX Shelf 4x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.47, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.47, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV1", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [-0.365, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divV2", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divV3", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0.365, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH1", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH2", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH3", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-standard",
|
||||||
|
"name": "BILLY Bookcase",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.28, "height": 2.02 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [-0.39, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [0.39, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.76, 2.0, 0.01], "position": [0, 1.01, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.4, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.8, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 1.2, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf5", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 1.6, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.80, 0.02, 0.28], "position": [0, 2.01, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-narrow",
|
||||||
|
"name": "BILLY Bookcase Narrow",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.40, "depth": 0.28, "height": 2.02 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [-0.19, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [0.19, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.36, 2.0, 0.01], "position": [0, 1.01, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.4, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.8, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 1.2, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf5", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 1.6, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.40, 0.02, 0.28], "position": [0, 2.01, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-short",
|
||||||
|
"name": "BILLY Bookcase Short",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.28, "height": 1.06 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 1.06, 0.28], "position": [-0.39, 0.53, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 1.06, 0.28], "position": [0.39, 0.53, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.76, 1.04, 0.01], "position": [0, 0.53, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.35, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.7, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.80, 0.02, 0.28], "position": [0, 1.05, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-6drawer",
|
||||||
|
"name": "HEMNES 6-Drawer Dresser",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.08, "depth": 0.50, "height": 1.31 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.08, 1.31, 0.50], "position": [0, 0.655, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.15, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.15, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.37, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.37, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer5", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.59, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer6", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.59, 0.24], "color": "#e8e4dc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-3drawer",
|
||||||
|
"name": "HEMNES 3-Drawer Dresser",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer", "flur"],
|
||||||
|
"dimensions": { "width": 1.08, "depth": 0.50, "height": 0.96 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.08, 0.96, 0.50], "position": [0, 0.48, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.18, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.44, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.70, 0.24], "color": "#e8e4dc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-bookcase",
|
||||||
|
"name": "HEMNES Bookcase",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.37, "height": 1.97 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.03, 1.97, 0.37], "position": [-0.435, 0.985, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.03, 1.97, 0.37], "position": [0.435, 0.985, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.84, 1.95, 0.01], "position": [0, 0.985, -0.18], "color": "#d4be97" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 0.01, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 0.5, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 1.0, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 1.5, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.02, 0.37], "position": [0, 1.96, 0], "color": "#c4a87d" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-besta-tv",
|
||||||
|
"name": "BESTA TV Bench",
|
||||||
|
"ikeaSeries": "BESTA",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 0.42, "height": 0.38 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.80, 0.38, 0.42], "position": [0, 0.19, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door1", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [-0.6, 0.19, 0.2], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door2", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [0, 0.19, 0.2], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door3", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [0.6, 0.19, 0.2], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-6drawer",
|
||||||
|
"name": "MALM 6-Drawer Dresser",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.48, "height": 1.23 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 1.23, 0.48], "position": [0, 0.615, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.12, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.32, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.52, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.72, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer5", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.92, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer6", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 1.12, 0.23], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-4drawer",
|
||||||
|
"name": "MALM 4-Drawer Dresser",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.48, "height": 1.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 1.00, 0.48], "position": [0, 0.50, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.14, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.38, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.62, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.86, 0.23], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-pax-wardrobe",
|
||||||
|
"name": "PAX Wardrobe 100cm",
|
||||||
|
"ikeaSeries": "PAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.00, "depth": 0.58, "height": 2.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.00, 2.01, 0.58], "position": [0, 1.005, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [-0.25, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0.25, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "handle_l", "geometry": "box", "size": [0.02, 0.12, 0.03], "position": [-0.02, 1.005, 0.3], "color": "#888888" },
|
||||||
|
{ "name": "handle_r", "geometry": "box", "size": [0.02, 0.12, 0.03], "position": [0.02, 1.005, 0.3], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-pax-wardrobe-150",
|
||||||
|
"name": "PAX Wardrobe 150cm",
|
||||||
|
"ikeaSeries": "PAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.50, "depth": 0.58, "height": 2.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.50, 2.01, 0.58], "position": [0, 1.005, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [-0.5, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_m", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0.5, 1.005, 0.28], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-side",
|
||||||
|
"name": "LACK Side Table",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.55, "depth": 0.55, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.55, 0.05, 0.55], "position": [0, 0.425, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.22, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.22, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.22, 0.2, 0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.22, 0.2, 0.22], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-coffee",
|
||||||
|
"name": "LACK Coffee Table",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.55, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.05, 0.55], "position": [0, 0.425, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [0.84, 0.02, 0.49], "position": [0, 0.07, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, 0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, 0.22], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-tv",
|
||||||
|
"name": "LACK TV Bench",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.26, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.04, 0.26], "position": [0, 0.43, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [0.84, 0.02, 0.22], "position": [0, 0.07, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, -0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, -0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, 0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, 0.08], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lisabo-desk",
|
||||||
|
"name": "LISABO Desk",
|
||||||
|
"ikeaSeries": "LISABO",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.18, "depth": 0.45, "height": 0.74 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.18, 0.03, 0.45], "position": [0, 0.725, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [-0.54, 0.355, -0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [0.54, 0.355, -0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [-0.54, 0.355, 0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [0.54, 0.355, 0.18], "color": "#b09870" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-bekant-desk",
|
||||||
|
"name": "BEKANT Desk 160x80",
|
||||||
|
"ikeaSeries": "BEKANT",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.60, "depth": 0.80, "height": 0.75 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.60, 0.03, 0.80], "position": [0, 0.735, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [-0.72, 0.36, -0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [0.72, 0.36, -0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [-0.72, 0.36, 0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [0.72, 0.36, 0.32], "color": "#cccccc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-melltorp-table",
|
||||||
|
"name": "MELLTORP Dining Table",
|
||||||
|
"ikeaSeries": "MELLTORP",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["esszimmer", "kueche"],
|
||||||
|
"dimensions": { "width": 1.25, "depth": 0.75, "height": 0.74 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.25, 0.03, 0.75], "position": [0, 0.725, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [-0.56, 0.355, -0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [0.56, 0.355, -0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [-0.56, 0.355, 0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [0.56, 0.355, 0.31], "color": "#e0e0e0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-ekedalen-table",
|
||||||
|
"name": "EKEDALEN Dining Table",
|
||||||
|
"ikeaSeries": "EKEDALEN",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["esszimmer"],
|
||||||
|
"dimensions": { "width": 1.20, "depth": 0.80, "height": 0.75 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.20, 0.04, 0.80], "position": [0, 0.73, 0], "color": "#6b5640" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [-0.52, 0.355, -0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [0.52, 0.355, -0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [-0.52, 0.355, 0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [0.52, 0.355, 0.32], "color": "#5a4530" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-poang-chair",
|
||||||
|
"name": "POANG Armchair",
|
||||||
|
"ikeaSeries": "POANG",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer", "schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.68, "depth": 0.82, "height": 1.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.55, 0.12, 0.55], "position": [0, 0.38, 0.08], "color": "#d4c4a0" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.55, 0.5, 0.08], "position": [0, 0.75, -0.30], "color": "#d4c4a0" },
|
||||||
|
{ "name": "frame_l", "geometry": "box", "size": [0.05, 0.95, 0.75], "position": [-0.30, 0.48, 0], "color": "#a08050" },
|
||||||
|
{ "name": "frame_r", "geometry": "box", "size": [0.05, 0.95, 0.75], "position": [0.30, 0.48, 0], "color": "#a08050" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-strandmon-chair",
|
||||||
|
"name": "STRANDMON Wing Chair",
|
||||||
|
"ikeaSeries": "STRANDMON",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.82, "depth": 0.96, "height": 1.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.60, 0.15, 0.55], "position": [0, 0.38, 0.1], "color": "#5a7060" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.65, 0.55, 0.12], "position": [0, 0.73, -0.35], "color": "#5a7060" },
|
||||||
|
{ "name": "wing_l", "geometry": "box", "size": [0.12, 0.45, 0.30], "position": [-0.35, 0.70, -0.15], "color": "#5a7060" },
|
||||||
|
{ "name": "wing_r", "geometry": "box", "size": [0.12, 0.45, 0.30], "position": [0.35, 0.70, -0.15], "color": "#5a7060" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.10, 0.20, 0.55], "position": [-0.36, 0.48, 0.1], "color": "#4a6050" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.10, 0.20, 0.55], "position": [0.36, 0.48, 0.1], "color": "#4a6050" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [-0.30, 0.075, 0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [0.30, 0.075, 0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [-0.30, 0.075, -0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [0.30, 0.075, -0.35], "color": "#3a3020" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-klippan-sofa",
|
||||||
|
"name": "KLIPPAN 2-Seat Sofa",
|
||||||
|
"ikeaSeries": "KLIPPAN",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 0.88, "height": 0.66 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [1.80, 0.35, 0.88], "position": [0, 0.175, 0], "color": "#3a3a3a" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [1.80, 0.31, 0.15], "position": [0, 0.505, -0.365], "color": "#3a3a3a" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.15, 0.50, 0.73], "position": [-0.825, 0.25, 0.075], "color": "#333333" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.15, 0.50, 0.73], "position": [0.825, 0.25, 0.075], "color": "#333333" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-ektorp-sofa",
|
||||||
|
"name": "EKTORP 3-Seat Sofa",
|
||||||
|
"ikeaSeries": "EKTORP",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 2.18, "depth": 0.88, "height": 0.88 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [2.18, 0.42, 0.88], "position": [0, 0.21, 0], "color": "#e8e0d4" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [2.18, 0.46, 0.15], "position": [0, 0.65, -0.365], "color": "#e8e0d4" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.18, 0.65, 0.88], "position": [-1.0, 0.325, 0], "color": "#ddd8cc" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.18, 0.65, 0.88], "position": [1.0, 0.325, 0], "color": "#ddd8cc" },
|
||||||
|
{ "name": "cushion1", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [-0.56, 0.47, 0.1], "color": "#ece4d8" },
|
||||||
|
{ "name": "cushion2", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [0, 0.47, 0.1], "color": "#ece4d8" },
|
||||||
|
{ "name": "cushion3", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [0.56, 0.47, 0.1], "color": "#ece4d8" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kivik-sofa",
|
||||||
|
"name": "KIVIK 3-Seat Sofa",
|
||||||
|
"ikeaSeries": "KIVIK",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 2.28, "depth": 0.95, "height": 0.83 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [2.28, 0.40, 0.95], "position": [0, 0.20, 0], "color": "#8899aa" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [2.10, 0.43, 0.18], "position": [0, 0.615, -0.385], "color": "#8899aa" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.20, 0.55, 0.95], "position": [-1.04, 0.275, 0], "color": "#7a8a9a" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.20, 0.55, 0.95], "position": [1.04, 0.275, 0], "color": "#7a8a9a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-markus-chair",
|
||||||
|
"name": "MARKUS Office Chair",
|
||||||
|
"ikeaSeries": "MARKUS",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.62, "depth": 0.60, "height": 1.35 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.50, 0.08, 0.48], "position": [0, 0.48, 0], "color": "#2a2a2a" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.48, 0.65, 0.06], "position": [0, 0.87, -0.24], "color": "#2a2a2a" },
|
||||||
|
{ "name": "headrest", "geometry": "box", "size": [0.30, 0.18, 0.06], "position": [0, 1.28, -0.24], "color": "#2a2a2a" },
|
||||||
|
{ "name": "pedestal", "geometry": "cylinder", "radius": 0.03, "height": 0.44, "position": [0, 0.22, 0], "color": "#666666" },
|
||||||
|
{ "name": "base", "geometry": "cylinder", "radius": 0.28, "height": 0.04, "position": [0, 0.02, 0], "color": "#444444" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.04, 0.04, 0.22], "position": [-0.27, 0.55, 0.05], "color": "#444444" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.04, 0.04, 0.22], "position": [0.27, 0.55, 0.05], "color": "#444444" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-queen",
|
||||||
|
"name": "MALM Bed Queen 160cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.60, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.60, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.50, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.60, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.38, 0.52, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.38, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-king",
|
||||||
|
"name": "MALM Bed King 180cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.80, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.70, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.80, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.45, 0.52, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.45, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-single",
|
||||||
|
"name": "MALM Bed Single 90cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.90, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.82, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [0.90, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-bed-queen",
|
||||||
|
"name": "HEMNES Bed Queen",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.63, "depth": 2.11, "height": 1.12 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.63, 0.30, 2.11], "position": [0, 0.15, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.53, 0.20, 2.0], "position": [0, 0.40, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.63, 0.82, 0.06], "position": [0, 0.71, -1.025], "color": "#b09870" },
|
||||||
|
{ "name": "footboard", "geometry": "box", "size": [1.63, 0.36, 0.04], "position": [0, 0.48, 1.035], "color": "#b09870" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.38, 0.54, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.38, 0.54, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kura-bed",
|
||||||
|
"name": "KURA Reversible Bed",
|
||||||
|
"ikeaSeries": "KURA",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.99, "depth": 2.09, "height": 1.16 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.99, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.90, 0.12, 1.98], "position": [0, 0.34, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "rail_l", "geometry": "box", "size": [0.03, 0.76, 2.09], "position": [-0.48, 0.66, 0], "color": "#b4a48c" },
|
||||||
|
{ "name": "rail_r", "geometry": "box", "size": [0.03, 0.76, 2.09], "position": [0.48, 0.66, 0], "color": "#b4a48c" },
|
||||||
|
{ "name": "top_frame", "geometry": "box", "size": [0.99, 0.04, 2.09], "position": [0, 1.14, 0], "color": "#b4a48c" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-base",
|
||||||
|
"name": "METOD Base Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.60, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 0.80, 0.60], "position": [0, 0.40, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door", "geometry": "box", "size": [0.56, 0.70, 0.02], "position": [0, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [0.60, 0.04, 0.62], "position": [0, 0.82, 0], "color": "#888888" },
|
||||||
|
{ "name": "handle", "geometry": "box", "size": [0.10, 0.02, 0.03], "position": [0, 0.62, 0.31], "color": "#aaaaaa" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-base-80",
|
||||||
|
"name": "METOD Base Cabinet 80cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.60, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 0.80, 0.60], "position": [0, 0.40, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.37, 0.70, 0.02], "position": [-0.19, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.37, 0.70, 0.02], "position": [0.19, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [0.80, 0.04, 0.62], "position": [0, 0.82, 0], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-wall",
|
||||||
|
"name": "METOD Wall Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.37, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 0.80, 0.37], "position": [0, 1.70, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door", "geometry": "box", "size": [0.56, 0.76, 0.02], "position": [0, 1.70, 0.175], "color": "#e0dcd4" },
|
||||||
|
{ "name": "handle", "geometry": "box", "size": [0.10, 0.02, 0.03], "position": [0, 1.40, 0.20], "color": "#aaaaaa" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-tall",
|
||||||
|
"name": "METOD Tall Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.60, "height": 2.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 2.00, 0.60], "position": [0, 1.00, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_top", "geometry": "box", "size": [0.56, 0.90, 0.02], "position": [0, 1.50, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_bot", "geometry": "box", "size": [0.56, 0.90, 0.02], "position": [0, 0.50, 0.29], "color": "#e0dcd4" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-vadholma-island",
|
||||||
|
"name": "VADHOLMA Kitchen Island",
|
||||||
|
"ikeaSeries": "VADHOLMA",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 1.26, "depth": 0.79, "height": 0.90 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.26, 0.85, 0.79], "position": [0, 0.425, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [1.30, 0.04, 0.83], "position": [0, 0.87, 0], "color": "#888888" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [1.16, 0.02, 0.69], "position": [0, 0.15, 0], "color": "#c4a87d" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-knoxhult-base",
|
||||||
|
"name": "KNOXHULT Base Cabinet 120cm",
|
||||||
|
"ikeaSeries": "KNOXHULT",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 1.20, "depth": 0.61, "height": 0.85 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.20, 0.85, 0.61], "position": [0, 0.425, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.38, 0.70, 0.02], "position": [-0.38, 0.40, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.38, 0.70, 0.02], "position": [0.38, 0.40, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "drawer", "geometry": "box", "size": [0.38, 0.18, 0.02], "position": [0, 0.70, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [1.20, 0.04, 0.63], "position": [0, 0.83, 0], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-linnmon-alex-desk",
|
||||||
|
"name": "LINNMON/ALEX Desk",
|
||||||
|
"ikeaSeries": "LINNMON",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 1.50, "depth": 0.75, "height": 0.73 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.50, 0.04, 0.75], "position": [0, 0.71, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer_unit", "geometry": "box", "size": [0.36, 0.58, 0.70], "position": [0.53, 0.29, -0.02], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.12, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.28, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.44, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.69, 0.04], "position": [-0.70, 0.345, -0.33], "color": "#cccccc" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.69, 0.04], "position": [-0.70, 0.345, 0.33], "color": "#cccccc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-sundvik-bed",
|
||||||
|
"name": "SUNDVIK Child Bed",
|
||||||
|
"ikeaSeries": "SUNDVIK",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 1.67, "height": 0.83 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.80, 0.25, 1.67], "position": [0, 0.125, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.70, 0.12, 1.56], "position": [0, 0.31, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [0.80, 0.58, 0.04], "position": [0, 0.54, -0.815], "color": "#e8e4dc" },
|
||||||
|
{ "name": "footboard", "geometry": "box", "size": [0.80, 0.40, 0.04], "position": [0, 0.45, 0.815], "color": "#e8e4dc" },
|
||||||
|
{ "name": "pillow", "geometry": "box", "size": [0.40, 0.06, 0.28], "position": [0, 0.40, -0.56], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
323
data/loft-modern.json
Normal file
323
data/loft-modern.json
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"name": "Modernes Loft",
|
||||||
|
"description": "Modern open-plan loft apartment, 1 floor, ~80sqm with high ceilings",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 10, "depth": 8 },
|
||||||
|
"wallThickness": 0.2,
|
||||||
|
"roofType": "flat"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Loft",
|
||||||
|
"nameEN": "Loft",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 3.2,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-eingang",
|
||||||
|
"name": "Eingang",
|
||||||
|
"nameEN": "Entry",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.0, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d2",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-buero"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-buero",
|
||||||
|
"name": "Home Office",
|
||||||
|
"nameEN": "Home Office",
|
||||||
|
"type": "office",
|
||||||
|
"position": { "x": 2.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 3.0, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-eingang"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 5.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ankleide",
|
||||||
|
"name": "Ankleide",
|
||||||
|
"nameEN": "Walk-in Closet",
|
||||||
|
"type": "storage",
|
||||||
|
"position": { "x": 7.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ank-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnbereich",
|
||||||
|
"name": "Wohn-/Essbereich mit Küche",
|
||||||
|
"nameEN": "Open Living/Dining/Kitchen",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"sillHeight": 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 2.8,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"sillHeight": 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w3",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w4",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Master Bedroom",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 5.0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
885
data/villa-large.json
Normal file
885
data/villa-large.json
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
{
|
||||||
|
"name": "Villa Sonnenhügel",
|
||||||
|
"description": "Large luxury villa, 2 floors, ~300sqm living space with generous rooms",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 15, "depth": 11 },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "hip"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Erdgeschoss",
|
||||||
|
"nameEN": "Ground Floor",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.8,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-foyer",
|
||||||
|
"name": "Foyer",
|
||||||
|
"nameEN": "Grand Foyer",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 5.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d2",
|
||||||
|
"type": "patio",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 2.2,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-kueche"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d4",
|
||||||
|
"type": "open",
|
||||||
|
"position": 5.0,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-gaeste-wc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d6",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 2.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-arbeitszimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d7",
|
||||||
|
"type": "open",
|
||||||
|
"position": 6.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-esszimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-kueche",
|
||||||
|
"name": "Küche",
|
||||||
|
"nameEN": "Kitchen",
|
||||||
|
"type": "kitchen",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer",
|
||||||
|
"nameEN": "Living Room",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 5.0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 6.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w4",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.8,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-gaeste-wc",
|
||||||
|
"name": "Gäste-WC",
|
||||||
|
"nameEN": "Guest WC",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 8.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-gwc-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.6,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-gwc-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-hwr",
|
||||||
|
"name": "Hauswirtschaftsraum",
|
||||||
|
"nameEN": "Utility Room",
|
||||||
|
"type": "utility",
|
||||||
|
"position": { "x": 10.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-hwr-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-arbeitszimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-arbeitszimmer",
|
||||||
|
"name": "Arbeitszimmer",
|
||||||
|
"nameEN": "Home Office / Library",
|
||||||
|
"type": "office",
|
||||||
|
"position": { "x": 8.0, "y": 2.0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 4.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-az-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-az-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-esszimmer",
|
||||||
|
"name": "Esszimmer",
|
||||||
|
"nameEN": "Dining Room",
|
||||||
|
"type": "dining",
|
||||||
|
"position": { "x": 8.0, "y": 6.0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.8,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og",
|
||||||
|
"name": "Obergeschoss",
|
||||||
|
"nameEN": "Upper Floor",
|
||||||
|
"level": 1,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "og-flur",
|
||||||
|
"name": "Flur",
|
||||||
|
"nameEN": "Upper Hallway",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 5.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-fl-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-kinderzimmer1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-elternbad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 6.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-fl-d4",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-kinderzimmer2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-badezimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d6",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 7.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-gaestezimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Master Suite",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 0, "y": 6.0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-elternbad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-sz-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-ankleide"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-sz-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-elternbad",
|
||||||
|
"name": "Elternbad",
|
||||||
|
"nameEN": "En-suite Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 0, "y": 3.0 },
|
||||||
|
"dimensions": { "width": 3.0, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-ankleide",
|
||||||
|
"name": "Ankleide",
|
||||||
|
"nameEN": "Walk-in Closet",
|
||||||
|
"type": "storage",
|
||||||
|
"position": { "x": 3.0, "y": 3.5 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-ak-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kinderzimmer1",
|
||||||
|
"name": "Kinderzimmer 1",
|
||||||
|
"nameEN": "Child's Room 1",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 3.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kinderzimmer2",
|
||||||
|
"name": "Kinderzimmer 2",
|
||||||
|
"nameEN": "Child's Room 2",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 8.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 3.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Family Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 8.0, "y": 3.5 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-gaestezimmer",
|
||||||
|
"name": "Gästezimmer",
|
||||||
|
"nameEN": "Guest Suite",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 8.0, "y": 7.5 },
|
||||||
|
"dimensions": { "width": 7.0, "length": 3.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-gz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 4.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
236
designs/apartment-small-design.json
Normal file
236
designs/apartment-small-design.json
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
{
|
||||||
|
"name": "Stadtwohnung Kompakt Einrichtung",
|
||||||
|
"description": "Furnished small city apartment, efficient use of space",
|
||||||
|
"houseFile": "data/apartment-small.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-flur",
|
||||||
|
"name": "Flur (Hallway)",
|
||||||
|
"dimensions": { "width": 1.5, "length": 7.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 1.325, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 0.02, "y": 1.5, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "On west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 0.75, "z": 0.9 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Entry area rug"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-kueche",
|
||||||
|
"name": "Küche (Kitchen)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Along south wall under window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.2, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above counter on south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 2.675 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, west corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 2.0, "z": 1.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Small dining area in kitchen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "chair-1",
|
||||||
|
"position": { "x": 1.4, "z": 1.8 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West side of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "chair-2",
|
||||||
|
"position": { "x": 2.6, "z": 1.8 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "East side of table"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer (Living Room)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 4.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 1.75, "z": 2.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward TV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 1.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 1.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On TV stand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 1.75, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Between sofa and TV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 3.2, "z": 2.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Corner next to sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 1.75, "z": 1.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under coffee table area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 0.3, "z": 3.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer (Bathroom)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 2.05, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.175, "z": 2.15 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 0.8, "z": 2.275 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 0.8, "y": 1.5, "z": 2.4 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Bedroom)",
|
||||||
|
"dimensions": { "width": 4.0, "length": 4.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.0, "z": 1.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered, headboard against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "nightstand-left",
|
||||||
|
"position": { "x": 0.875, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "nightstand-right",
|
||||||
|
"position": { "x": 3.125, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 3.0, "z": 4.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 3.5, "z": 4.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 2.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left of bed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
504
designs/floorplan-import-design.md
Normal file
504
designs/floorplan-import-design.md
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
# Floor Plan Image Recognition — Feature Design
|
||||||
|
|
||||||
|
**Task:** t-c2921
|
||||||
|
**Author:** inventor
|
||||||
|
**Status:** Design proposal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users want to import an existing floor plan image (architect drawing, realtor photo, hand sketch) and have it automatically converted into the project's house JSON format so they can immediately view it in 3D, furnish rooms, and iterate on the design.
|
||||||
|
|
||||||
|
## Approach: LLM Vision API
|
||||||
|
|
||||||
|
After evaluating four approaches, the recommended solution uses **multimodal LLM vision** (Claude or OpenAI) to analyze floor plan images and output structured house JSON.
|
||||||
|
|
||||||
|
### Why LLM Vision over alternatives
|
||||||
|
|
||||||
|
| Approach | Pros | Cons | Verdict |
|
||||||
|
|----------|------|------|---------|
|
||||||
|
| **Classical CV** (OpenCV.js edge detection) | No API needed, offline | Can't identify room types, fails on varied styles, needs heavy heuristics | Too fragile |
|
||||||
|
| **LLM Vision** (Claude/GPT-4V) | Understands semantics, handles variety, outputs JSON directly | Needs API key + network | **Best fit** |
|
||||||
|
| **Dedicated ML** (YOLO/CubiCasa models) | High accuracy for specific styles | Heavy model files (~100MB+), complex setup, breaks vanilla JS philosophy | Too heavy |
|
||||||
|
| **Hybrid CV + LLM** | Best of both worlds | More complexity for marginal gain | Overengineered for v1 |
|
||||||
|
|
||||||
|
**Key reasons:**
|
||||||
|
1. Project is vanilla JS with no build system — adding ML runtimes is architecturally wrong
|
||||||
|
2. Floor plans are inherently semantic — you need to know "this is a kitchen" not just "this is a rectangle"
|
||||||
|
3. LLMs can output the exact house JSON format in a single call
|
||||||
|
4. LLMs handle architectural drawings, realtor floor plans, and hand sketches equally well
|
||||||
|
5. Standard door widths (~0.9m) give LLMs reliable dimensional anchors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New module: `src/floorplan-import.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
FloorplanImporter
|
||||||
|
├── constructor(renderer, options)
|
||||||
|
├── open() // Shows the import modal
|
||||||
|
├── _buildModal() // Creates DOM for the modal overlay
|
||||||
|
├── _handleImageUpload(file) // Processes uploaded image
|
||||||
|
├── _preprocessImage(imageData) // Canvas preprocessing (contrast, resize)
|
||||||
|
├── _analyzeWithLLM(base64Image) // Sends to vision API, gets house JSON
|
||||||
|
├── _buildPrompt() // Constructs the system+user prompt
|
||||||
|
├── _validateHouseJSON(json) // Validates output matches schema
|
||||||
|
├── _applyToRenderer(houseData) // Loads result into the 3D viewer
|
||||||
|
├── _showPreview(houseData) // Shows result for user review
|
||||||
|
└── close() // Closes modal, cleans up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration point: `src/index.html`
|
||||||
|
|
||||||
|
New button in the sidebar File section:
|
||||||
|
```html
|
||||||
|
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired in the `wireExportButtons()` function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Import Floor Plan" in sidebar
|
||||||
|
│
|
||||||
|
2. Modal overlay appears with:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Import Floor Plan │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Drop image here or │ │
|
||||||
|
│ │ click to browse │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ PNG, JPG, WebP │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Building name: [___________] │
|
||||||
|
│ Floors shown: [1 ▼] │
|
||||||
|
│ │
|
||||||
|
│ API: [Claude ▼] Key: [••••••] │
|
||||||
|
│ │
|
||||||
|
│ [Analyze Floor Plan] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
3. Image uploaded → shown in preview area
|
||||||
|
│
|
||||||
|
4. User clicks "Analyze" → spinner + progress text
|
||||||
|
│
|
||||||
|
5. LLM returns house JSON
|
||||||
|
│
|
||||||
|
6. Preview mode shows:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Result Preview │
|
||||||
|
│ │
|
||||||
|
│ Found: 6 rooms, 8 doors, │
|
||||||
|
│ 12 windows │
|
||||||
|
│ │
|
||||||
|
│ Rooms: │
|
||||||
|
│ ☑ Living Room 4.5 × 5.5m │
|
||||||
|
│ ☑ Kitchen 4.0 × 3.5m │
|
||||||
|
│ ☑ Hallway 2.0 × 9.0m │
|
||||||
|
│ ☑ Bathroom 2.5 × 3.0m │
|
||||||
|
│ ☑ Bedroom 4.5 × 4.0m │
|
||||||
|
│ ☑ Office 3.5 × 3.0m │
|
||||||
|
│ │
|
||||||
|
│ [Accept & Load] [Edit JSON] │
|
||||||
|
│ [Re-analyze] [Cancel] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
7a. "Accept" → loads house JSON into renderer,
|
||||||
|
rebuilds floor buttons, room list, 3D view
|
||||||
|
│
|
||||||
|
7b. "Edit JSON" → opens raw JSON in textarea
|
||||||
|
for manual corrections before loading
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Prompt (Core of the Feature)
|
||||||
|
|
||||||
|
The prompt engineering is the most critical part. It must produce valid house JSON from any floor plan style.
|
||||||
|
|
||||||
|
### System prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a floor plan analyzer. Given an image of a floor plan or floor layout,
|
||||||
|
extract the room structure and output valid JSON matching the exact schema below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All dimensions in meters. Use standard architectural conventions if no scale bar
|
||||||
|
is visible (standard interior door = 0.9m wide, entry door = 1.0-1.1m)
|
||||||
|
- Rooms are axis-aligned rectangles positioned on a coordinate grid
|
||||||
|
- Position {x, y} is the room's bottom-left corner (x = west-east, y = south-north)
|
||||||
|
- Each room has walls on 4 cardinal directions (north, south, east, west)
|
||||||
|
- Walls are "exterior" if they face outside the building, "interior" otherwise
|
||||||
|
- Doors have: id, type (entry|interior|patio|open), position (meters from wall start),
|
||||||
|
width, height, connectsTo (adjacent room id or "exterior")
|
||||||
|
- Windows have: id, type (casement|fixed), position, width, height, sillHeight
|
||||||
|
- Generate unique IDs: "{floorId}-{roomSlug}" for rooms, "{roomId}-d{n}" for doors,
|
||||||
|
"{roomId}-w{n}" for windows
|
||||||
|
- Room types: living, kitchen, dining, bedroom, bathroom, hallway, office, utility,
|
||||||
|
storage, laundry, garage
|
||||||
|
- Flooring: "tile" for kitchen/bathroom/utility/hallway, "hardwood" for others
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences, no explanation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt template
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze this floor plan image. The building is named "{name}".
|
||||||
|
{scaleHint ? "Scale reference: " + scaleHint : "Estimate dimensions from standard door widths."}
|
||||||
|
This image shows {floorCount} floor(s).
|
||||||
|
|
||||||
|
Output the house JSON with this structure:
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": <number>, "depth": <number> },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "gable"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-room-slug",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"type": "living|kitchen|...",
|
||||||
|
"position": { "x": <meters>, "y": <meters> },
|
||||||
|
"dimensions": { "width": <meters>, "length": <meters> },
|
||||||
|
"flooring": "tile|hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": { "type": "exterior|interior", "doors": [...], "windows": [...] },
|
||||||
|
"north": { ... },
|
||||||
|
"east": { ... },
|
||||||
|
"west": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Multi-provider support
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude (Anthropic)',
|
||||||
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.content[0].text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (GPT-4o)',
|
||||||
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64Image}` } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API key management
|
||||||
|
|
||||||
|
- Stored in `localStorage` under `floorplan-api-key-{provider}`
|
||||||
|
- Entered once per session via the import modal
|
||||||
|
- Never sent to any server except the chosen API provider
|
||||||
|
- Key input field uses `type="password"` and shows masked value
|
||||||
|
- "Clear key" button to remove from localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Preprocessing
|
||||||
|
|
||||||
|
Before sending to the LLM, apply lightweight canvas preprocessing:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_preprocessImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
// Resize if larger than 2048px on any side (API limits + cost reduction)
|
||||||
|
const maxDim = 2048;
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw and optionally enhance contrast for faded plans
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert to base64 (JPEG for photos, PNG for drawings)
|
||||||
|
const mediaType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = mediaType === 'image/jpeg' ? 0.9 : undefined;
|
||||||
|
const base64 = canvas.toDataURL(mediaType, quality).split(',')[1];
|
||||||
|
|
||||||
|
resolve({ base64, mediaType, width, height });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After receiving LLM output, validate before loading:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_validateHouseJSON(data) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!data.name) errors.push('Missing building name');
|
||||||
|
if (!data.building?.footprint) errors.push('Missing building footprint');
|
||||||
|
if (!data.floors?.length) errors.push('No floors found');
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.rooms?.length) {
|
||||||
|
errors.push(`Floor "${floor.name}" has no rooms`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
if (!room.id) errors.push(`Room missing id`);
|
||||||
|
if (!room.position) errors.push(`Room "${room.id}" missing position`);
|
||||||
|
if (!room.dimensions) errors.push(`Room "${room.id}" missing dimensions`);
|
||||||
|
if (!room.walls) errors.push(`Room "${room.id}" missing walls`);
|
||||||
|
|
||||||
|
// Validate wall references
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
const wall = room.walls?.[dir];
|
||||||
|
if (!wall) errors.push(`Room "${room.id}" missing ${dir} wall`);
|
||||||
|
if (wall && !['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
errors.push(`Room "${room.id}" ${dir} wall has invalid type "${wall.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-repair
|
||||||
|
|
||||||
|
Common LLM output issues and fixes:
|
||||||
|
- Missing wall entries → default to `{ "type": "interior" }`
|
||||||
|
- String numbers → parse to float
|
||||||
|
- Missing IDs → auto-generate from room name
|
||||||
|
- Missing flooring → infer from room type
|
||||||
|
- Rooms without walls object → generate empty walls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scale Detection Strategy
|
||||||
|
|
||||||
|
Dimensions are the hardest part. The LLM handles this through:
|
||||||
|
|
||||||
|
1. **Standard references** — Interior doors are ~0.9m, entry doors ~1.0-1.1m, windows ~1.2m. The LLM uses these as implicit scale anchors.
|
||||||
|
|
||||||
|
2. **User-provided scale** — Optional input: "The living room is approximately 5m wide" or "Scale: 1cm = 0.5m". Passed as a hint in the prompt.
|
||||||
|
|
||||||
|
3. **Scale bar detection** — If the floor plan has a scale bar, the LLM reads it directly.
|
||||||
|
|
||||||
|
4. **Post-import adjustment** — After loading, user can use the existing House Editor to manually adjust any room dimensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading into Renderer
|
||||||
|
|
||||||
|
After validation, the house JSON replaces the current house:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_applyToRenderer(houseData) {
|
||||||
|
// Replace house data in renderer
|
||||||
|
this.renderer.houseData = houseData;
|
||||||
|
this.renderer.currentFloor = 0;
|
||||||
|
|
||||||
|
// Clear and re-render
|
||||||
|
this.renderer._clearFloor();
|
||||||
|
const floor = houseData.floors[0];
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this.renderer._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for UI to rebuild floor buttons, room list, etc.
|
||||||
|
this.renderer.container.dispatchEvent(new CustomEvent('houseloaded', {
|
||||||
|
detail: { name: houseData.name, floors: houseData.floors.length }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `index.html` would listen for `houseloaded` and rebuild:
|
||||||
|
- Floor buttons
|
||||||
|
- Room list
|
||||||
|
- House editor state
|
||||||
|
- Reset camera position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
floorplan-import.js # New module — FloorplanImporter class
|
||||||
|
index.html # Modified — add button + wire up + houseloaded event
|
||||||
|
```
|
||||||
|
|
||||||
|
No new dependencies. No build changes. Pure vanilla JS using:
|
||||||
|
- `fetch()` for API calls
|
||||||
|
- `Canvas API` for image preprocessing
|
||||||
|
- `FileReader` / `Blob` for image handling
|
||||||
|
- `localStorage` for API key persistence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS (inline in modal, consistent with project style)
|
||||||
|
|
||||||
|
The modal uses the same design language as existing UI:
|
||||||
|
- `rgba(255, 255, 255, 0.95)` backgrounds
|
||||||
|
- `#4a90d9` accent color
|
||||||
|
- `-apple-system, BlinkMacSystemFont` font stack
|
||||||
|
- `border-radius: 4-6px` on elements
|
||||||
|
- Same button styles as `.export-btn`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Handling |
|
||||||
|
|------|----------|
|
||||||
|
| Multi-floor image (side by side) | Prompt asks LLM to detect multiple floors |
|
||||||
|
| Hand-drawn sketch | LLM handles well; dimensions will be approximate |
|
||||||
|
| Photo of printed plan | Canvas preprocessing helps; LLM reads spatial layout |
|
||||||
|
| Non-English labels | LLM translates; output uses both original + English names |
|
||||||
|
| Very large image (>10MB) | Canvas resizes to max 2048px before base64 encoding |
|
||||||
|
| LLM returns invalid JSON | Parse error → show raw text → let user "Edit JSON" |
|
||||||
|
| LLM returns partial data | Validation finds gaps → auto-repair what's possible, flag rest |
|
||||||
|
| API rate limit | Show error, suggest retry after delay |
|
||||||
|
| No API key | Modal won't allow "Analyze" without key entered |
|
||||||
|
| Curved walls / non-rectangular rooms | Approximate as rectangles (project constraint) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Estimate
|
||||||
|
|
||||||
|
Per floor plan analysis:
|
||||||
|
- **Claude Sonnet**: ~$0.01-0.03 per image (vision + ~2K output tokens)
|
||||||
|
- **GPT-4o**: ~$0.01-0.05 per image
|
||||||
|
- Negligible for individual use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Recommendations
|
||||||
|
|
||||||
|
### For the coder:
|
||||||
|
|
||||||
|
1. **Start with the prompt** — get `_buildPrompt()` right first, test with various floor plan images manually via the API before building the UI.
|
||||||
|
|
||||||
|
2. **Build the modal** — follow the existing modal-free overlay pattern (the project uses no modal library; use a simple overlay div).
|
||||||
|
|
||||||
|
3. **Wire up the API** — start with Claude support, add OpenAI second. The provider abstraction makes this easy.
|
||||||
|
|
||||||
|
4. **Add validation + auto-repair** — defensive parsing of LLM output is essential.
|
||||||
|
|
||||||
|
5. **Handle the `houseloaded` event** in index.html — rebuild all sidebar UI.
|
||||||
|
|
||||||
|
6. **Test with varied floor plans:**
|
||||||
|
- Clean architectural drawing (should work great)
|
||||||
|
- Realtor-style colored floor plan (should work well)
|
||||||
|
- Hand sketch on paper (should work, approximate dimensions)
|
||||||
|
- Photo of a floor plan on screen (should work with preprocessing)
|
||||||
|
|
||||||
|
### Testing approach:
|
||||||
|
- Save example floor plan images in `data/test-floorplans/`
|
||||||
|
- Compare LLM output against manually created house JSON
|
||||||
|
- Check that output loads in 3D viewer without errors
|
||||||
|
- Verify rooms don't overlap and walls connect properly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (out of scope for v1)
|
||||||
|
|
||||||
|
- **Local model support** — Run a local vision model (via Ollama) for offline use
|
||||||
|
- **PDF import** — Extract floor plan pages from architectural PDFs
|
||||||
|
- **Multi-floor stitching** — Upload separate images per floor, align them
|
||||||
|
- **Overlay comparison** — Show original image as ground texture under 3D rooms
|
||||||
|
- **Iterative refinement** — "The kitchen should be wider" → re-prompt with corrections
|
||||||
|
- **Scale calibration tool** — Click two points on image, enter real distance
|
||||||
317
designs/loft-modern-design.json
Normal file
317
designs/loft-modern-design.json
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
{
|
||||||
|
"name": "Modernes Loft Einrichtung",
|
||||||
|
"description": "Contemporary furnished loft with open-plan living and industrial-modern style",
|
||||||
|
"houseFile": "data/loft-modern.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-eingang",
|
||||||
|
"name": "Eingang (Entry)",
|
||||||
|
"dimensions": { "width": 2.0, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 0.175, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coat-rack",
|
||||||
|
"position": { "x": 1.85, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-buero",
|
||||||
|
"name": "Home Office",
|
||||||
|
"dimensions": { "width": 3.0, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "office-chair",
|
||||||
|
"position": { "x": 1.5, "z": 1.1 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "At desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 2.85, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 2.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer (Bathroom)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 0.45, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 2.275, "z": 1.2 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, central"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 2.4, "y": 1.5, "z": 1.2 },
|
||||||
|
"rotation": 270,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 1.5, "z": 2.175 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-ankleide",
|
||||||
|
"name": "Ankleide (Walk-in Closet)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 1.25 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 2.35, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, for accessories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 1.25, "y": 1.3, "z": 0.02 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Full-length mirror on south wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnbereich",
|
||||||
|
"name": "Wohn-/Essbereich mit Küche (Open Living/Dining/Kitchen)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-3seat",
|
||||||
|
"position": { "x": 2.0, "z": 3.8 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.0, "z": 2.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "In front of sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 0.5, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Side chair facing east"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 4.775, "z": 3.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 4.775, "z": 3.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "On TV stand, against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 3.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under living area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 4.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Behind armchair area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Kitchen counter along south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.2, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above kitchen counter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 0.325 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, kitchen area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-island",
|
||||||
|
"position": { "x": 2.5, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Kitchen island / breakfast bar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 4.0, "z": 1.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Dining area, east side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-1",
|
||||||
|
"position": { "x": 3.4, "z": 1.2 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-2",
|
||||||
|
"position": { "x": 4.0, "z": 1.75 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-3",
|
||||||
|
"position": { "x": 4.0, "z": 0.65 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"instanceId": "plant-2",
|
||||||
|
"position": { "x": 4.7, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 4.8, "z": 4.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, between living and dining"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Master Bedroom)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.5, "z": 1.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered, headboard against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-left",
|
||||||
|
"position": { "x": 1.375, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-right",
|
||||||
|
"position": { "x": 3.625, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 5.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 2.0, "z": 5.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 4.2, "z": 4.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Reading corner, facing west"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 4.7, "z": 4.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to reading chair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 0.15, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
761
designs/villa-large-design.json
Normal file
761
designs/villa-large-design.json
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
{
|
||||||
|
"name": "Villa Sonnenhügel Einrichtung",
|
||||||
|
"description": "Luxurious furnished villa with premium furnishings throughout",
|
||||||
|
"houseFile": "data/villa-large.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-foyer",
|
||||||
|
"name": "Foyer (Grand Foyer)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "console-table",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, near entrance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 0.02, "y": 1.5, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above console table on west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 2.325, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall near entry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coat-rack",
|
||||||
|
"position": { "x": 2.35, "z": 8.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north section"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 9.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near garden door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 1.25, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Entry area rug"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-kueche",
|
||||||
|
"name": "Küche (Kitchen)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Along south wall under windows"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above counter on south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"instanceId": "counter-west",
|
||||||
|
"position": { "x": 0.3, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Along west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 4.675 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, west corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-island",
|
||||||
|
"position": { "x": 3.0, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Large central island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "bar-1",
|
||||||
|
"position": { "x": 2.4, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Bar stool at island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "bar-2",
|
||||||
|
"position": { "x": 3.6, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Bar stool at island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 5.2, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer (Living Room)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 6.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-3seat",
|
||||||
|
"position": { "x": 2.75, "z": 3.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Centered, facing south"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 0.5, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Perpendicular to main sofa, facing east"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 5.0, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Facing west, opposite loveseat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.75, "z": 2.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center of seating area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 2.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 2.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On TV stand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "shelf-1",
|
||||||
|
"position": { "x": 5.35, "z": 3.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "shelf-2",
|
||||||
|
"position": { "x": 5.35, "z": 4.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, next to first shelf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 0.2, "z": 4.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.75, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under seating area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 3.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to loveseat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"instanceId": "lamp-2",
|
||||||
|
"position": { "x": 5.2, "z": 3.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"instanceId": "plant-2",
|
||||||
|
"position": { "x": 5.2, "z": 5.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-gaeste-wc",
|
||||||
|
"name": "Gäste-WC (Guest WC)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.175, "z": 1.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.0, "z": 1.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-hwr",
|
||||||
|
"name": "Hauswirtschaftsraum (Utility Room)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-arbeitszimmer",
|
||||||
|
"name": "Arbeitszimmer (Home Office / Library)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 4.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 3.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward east wall window area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "office-chair",
|
||||||
|
"position": { "x": 3.5, "z": 1.1 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "At desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 4.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-1",
|
||||||
|
"position": { "x": 0.15, "z": 1.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-2",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-3",
|
||||||
|
"position": { "x": 0.15, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 2.5, "z": 3.575 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall for reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 1.5, "z": 3.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to sofa for reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "In front of sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 4.7, "z": 3.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-esszimmer",
|
||||||
|
"name": "Esszimmer (Dining Room)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered in room"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n1",
|
||||||
|
"position": { "x": 1.9, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n2",
|
||||||
|
"position": { "x": 3.1, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s1",
|
||||||
|
"position": { "x": 1.9, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s2",
|
||||||
|
"position": { "x": 3.1, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-w",
|
||||||
|
"position": { "x": 1.2, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-e",
|
||||||
|
"position": { "x": 3.8, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "East end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n3",
|
||||||
|
"position": { "x": 2.5, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s3",
|
||||||
|
"position": { "x": 2.5, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 0.2, "z": 3.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"instanceId": "sideboard-2",
|
||||||
|
"position": { "x": 4.8, "z": 3.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under dining table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-flur",
|
||||||
|
"name": "Flur OG (Upper Hallway)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "console-table",
|
||||||
|
"position": { "x": 0.15, "z": 5.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 1.25, "z": 3.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Hallway runner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 2.2, "z": 5.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On console table area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Master Suite)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.75, "z": 3.85 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-left",
|
||||||
|
"position": { "x": 1.625, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-right",
|
||||||
|
"position": { "x": 3.875, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 0.5, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Reading corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 2.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.75, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under and beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"instanceId": "bedside-1",
|
||||||
|
"position": { "x": 1.625, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On left nightstand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"instanceId": "bedside-2",
|
||||||
|
"position": { "x": 3.875, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On right nightstand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 5.2, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-elternbad",
|
||||||
|
"name": "Elternbad (En-suite Bathroom)",
|
||||||
|
"dimensions": { "width": 3.0, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "bathtub",
|
||||||
|
"position": { "x": 0.375, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, under window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 2.55, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.5, "z": 2.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.5, "z": 2.9 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.675, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-ankleide",
|
||||||
|
"name": "Ankleide (Walk-in Closet)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 1.25 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"instanceId": "wardrobe-2",
|
||||||
|
"position": { "x": 2.2, "z": 1.25 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-kinderzimmer1",
|
||||||
|
"name": "Kinderzimmer 1 (Child's Room 1)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "single-bed",
|
||||||
|
"position": { "x": 0.8, "z": 2.0 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, west side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"position": { "x": 1.55, "z": 2.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 2.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against south wall facing window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 3.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-wardrobe",
|
||||||
|
"position": { "x": 5.225, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north of door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 4.0, "z": 2.85 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 5.35, "z": 0.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, south of wardrobe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 2.75, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center play area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-kinderzimmer2",
|
||||||
|
"name": "Kinderzimmer 2 (Child's Room 2)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 3.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "single-bed",
|
||||||
|
"position": { "x": 4.0, "z": 2.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, east side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"position": { "x": 3.275, "z": 3.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against south wall facing window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 1.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-wardrobe",
|
||||||
|
"position": { "x": 0.275, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, north of door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 0.15, "z": 0.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, south of wardrobe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 2.5, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center play area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-badezimmer",
|
||||||
|
"name": "Badezimmer (Family Bathroom)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "bathtub",
|
||||||
|
"position": { "x": 1.5, "z": 0.375 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Along south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.5, "z": 2.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.5, "z": 2.9 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 3.175, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 3.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, east side"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-gaestezimmer",
|
||||||
|
"name": "Gästezimmer (Guest Suite)",
|
||||||
|
"dimensions": { "width": 7.0, "length": 3.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.0, "z": 2.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, west portion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "guest-ns-l",
|
||||||
|
"position": { "x": 0.875, "z": 3.1 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "guest-ns-r",
|
||||||
|
"position": { "x": 3.125, "z": 3.1 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 5.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, east portion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 5.5, "z": 3.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, east portion near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 6.0, "z": 3.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 4.5, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Reading area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 4.2, "z": 2.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under bed area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "house-design",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
screenshot-ikea.png
Normal file
BIN
screenshot-ikea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
BIN
screenshot-phase2.png
Normal file
BIN
screenshot-phase2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
361
scripts/import-ikea-hf.js
Normal file
361
scripts/import-ikea-hf.js
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* IKEA HuggingFace Dataset Importer
|
||||||
|
*
|
||||||
|
* Fetches product data from the tsazan/ikea-us-commercetxt dataset on HuggingFace
|
||||||
|
* and converts items with valid dimensions into our catalog JSON format.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/import-ikea-hf.js [--limit N] [--output path]
|
||||||
|
*
|
||||||
|
* The HuggingFace dataset stores products in CommerceTXT format where each row
|
||||||
|
* is a line of text. Products are spread across multiple rows with sections like
|
||||||
|
* @PRODUCT, @SPECS, @IMAGES. This script streams through rows, reassembles
|
||||||
|
* product records, extracts dimensions, and generates procedural box meshes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DATASET = 'tsazan/ikea-us-commercetxt';
|
||||||
|
const API_BASE = 'https://datasets-server.huggingface.co';
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
// Category mapping from IKEA categories to our catalog categories
|
||||||
|
const CATEGORY_MAP = {
|
||||||
|
'sofas': 'seating',
|
||||||
|
'armchairs': 'seating',
|
||||||
|
'chairs': 'seating',
|
||||||
|
'dining chairs': 'seating',
|
||||||
|
'office chairs': 'office',
|
||||||
|
'desk chairs': 'office',
|
||||||
|
'desks': 'tables',
|
||||||
|
'dining tables': 'tables',
|
||||||
|
'coffee tables': 'tables',
|
||||||
|
'side tables': 'tables',
|
||||||
|
'console tables': 'tables',
|
||||||
|
'nightstands': 'tables',
|
||||||
|
'bedside tables': 'tables',
|
||||||
|
'bookcases': 'storage',
|
||||||
|
'shelving units': 'storage',
|
||||||
|
'shelf units': 'storage',
|
||||||
|
'dressers': 'storage',
|
||||||
|
'chests of drawers': 'storage',
|
||||||
|
'wardrobes': 'storage',
|
||||||
|
'tv stands': 'storage',
|
||||||
|
'tv benches': 'storage',
|
||||||
|
'sideboards': 'storage',
|
||||||
|
'cabinets': 'storage',
|
||||||
|
'beds': 'beds',
|
||||||
|
'bed frames': 'beds',
|
||||||
|
'kitchen cabinets': 'kitchen',
|
||||||
|
'kitchen islands': 'kitchen',
|
||||||
|
'base cabinets': 'kitchen',
|
||||||
|
'wall cabinets': 'kitchen',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Room mapping based on category
|
||||||
|
const ROOM_MAP = {
|
||||||
|
'seating': ['wohnzimmer'],
|
||||||
|
'tables': ['wohnzimmer', 'esszimmer'],
|
||||||
|
'storage': ['wohnzimmer', 'arbeitszimmer'],
|
||||||
|
'beds': ['schlafzimmer'],
|
||||||
|
'kitchen': ['kueche'],
|
||||||
|
'office': ['arbeitszimmer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse dimension string like '23⅝"' or '50¾"' to meters
|
||||||
|
function parseInchDim(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
str = str.trim().replace(/"/g, '').replace(/'/g, '');
|
||||||
|
|
||||||
|
// Handle fractions like ⅝, ¾, ½, ¼, ⅜, ⅞
|
||||||
|
const fractions = { '⅛': 0.125, '¼': 0.25, '⅜': 0.375, '½': 0.5, '⅝': 0.625, '¾': 0.75, '⅞': 0.875 };
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
for (const [frac, num] of Object.entries(fractions)) {
|
||||||
|
if (str.includes(frac)) {
|
||||||
|
str = str.replace(frac, '');
|
||||||
|
value += num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numPart = parseFloat(str);
|
||||||
|
if (!isNaN(numPart)) value += numPart;
|
||||||
|
|
||||||
|
// Convert inches to meters
|
||||||
|
return value > 0 ? Math.round(value * 0.0254 * 1000) / 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a dimensions line from @SPECS section
|
||||||
|
// Examples: "Width: 23⅝" and 50¾".", "Height: 29½"", "Depth: 15⅜""
|
||||||
|
function parseDimensions(specsLines) {
|
||||||
|
let width = null, height = null, depth = null;
|
||||||
|
|
||||||
|
for (const line of specsLines) {
|
||||||
|
const lower = line.toLowerCase();
|
||||||
|
|
||||||
|
// Try "Width: X" pattern
|
||||||
|
const wMatch = line.match(/Width:\s*([^,.\n]+)/i);
|
||||||
|
if (wMatch) {
|
||||||
|
// Take first value if multiple ("23⅝" and 50¾"")
|
||||||
|
const parts = wMatch[1].split(/\s+and\s+/);
|
||||||
|
width = parseInchDim(parts[parts.length - 1]); // take largest
|
||||||
|
}
|
||||||
|
|
||||||
|
const hMatch = line.match(/Height:\s*([^,.\n]+)/i);
|
||||||
|
if (hMatch) {
|
||||||
|
const parts = hMatch[1].split(/\s+and\s+/);
|
||||||
|
height = parseInchDim(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dMatch = line.match(/Depth:\s*([^,.\n]+)/i);
|
||||||
|
if (dMatch) {
|
||||||
|
const parts = dMatch[1].split(/\s+and\s+/);
|
||||||
|
depth = parseInchDim(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try "WxDxH" or "W"xD"xH"" pattern
|
||||||
|
const xMatch = line.match(/(\d+[⅛¼⅜½⅝¾⅞]?)"?\s*x\s*(\d+[⅛¼⅜½⅝¾⅞]?)"?\s*x\s*(\d+[⅛¼⅜½⅝¾⅞]?)"/i);
|
||||||
|
if (xMatch) {
|
||||||
|
width = width || parseInchDim(xMatch[1]);
|
||||||
|
depth = depth || parseInchDim(xMatch[2]);
|
||||||
|
height = height || parseInchDim(xMatch[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width && height && depth) {
|
||||||
|
return { width, depth, height };
|
||||||
|
}
|
||||||
|
// At minimum need width and one other
|
||||||
|
if (width && (height || depth)) {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
depth: depth || Math.round(width * 0.5 * 1000) / 1000,
|
||||||
|
height: height || Math.round(width * 0.8 * 1000) / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple procedural box mesh from dimensions
|
||||||
|
function generateMesh(dims, category) {
|
||||||
|
const { width, depth, height } = dims;
|
||||||
|
const color = {
|
||||||
|
seating: '#7a8a9a',
|
||||||
|
tables: '#b09870',
|
||||||
|
storage: '#f0ece4',
|
||||||
|
beds: '#f5f0eb',
|
||||||
|
kitchen: '#e0dcd4',
|
||||||
|
office: '#cccccc',
|
||||||
|
}[category] || '#aaaaaa';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
geometry: 'box',
|
||||||
|
size: [width, height, depth],
|
||||||
|
position: [0, height / 2, 0],
|
||||||
|
color
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug ID from product name
|
||||||
|
function slugify(name) {
|
||||||
|
return 'ikea-hf-' + name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[äöü]/g, c => ({ 'ä': 'ae', 'ö': 'oe', 'ü': 'ue' }[c]))
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guess category from product name/context
|
||||||
|
function guessCategory(name, contextCategory) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (/sofa|couch|loveseat/i.test(lower)) return 'seating';
|
||||||
|
if (/chair|armchair|stool/i.test(lower)) return 'seating';
|
||||||
|
if (/desk|table/i.test(lower)) return 'tables';
|
||||||
|
if (/shelf|bookcase|shelving|kallax|billy/i.test(lower)) return 'storage';
|
||||||
|
if (/dresser|drawer|wardrobe|pax|malm.*drawer/i.test(lower)) return 'storage';
|
||||||
|
if (/tv.*bench|tv.*stand|besta|bestå/i.test(lower)) return 'storage';
|
||||||
|
if (/bed|mattress/i.test(lower)) return 'beds';
|
||||||
|
if (/cabinet|kitchen|metod|knoxhult/i.test(lower)) return 'kitchen';
|
||||||
|
if (/office/i.test(lower)) return 'office';
|
||||||
|
|
||||||
|
// Try context category
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_MAP)) {
|
||||||
|
if (contextCategory && contextCategory.toLowerCase().includes(key)) return cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'storage'; // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract IKEA series name from product name
|
||||||
|
function extractSeries(name) {
|
||||||
|
// IKEA series are typically the first all-caps word
|
||||||
|
const match = name.match(/^([A-ZÅÄÖ]{2,})/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRows(offset, length) {
|
||||||
|
const url = `${API_BASE}/rows?dataset=${DATASET}&config=default&split=train&offset=${offset}&length=${length}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`API error: ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.rows?.map(r => r.row?.text || '') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDataset(maxItems = 50) {
|
||||||
|
console.error(`Fetching IKEA products from HuggingFace (limit: ${maxItems})...`);
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
let offset = 0;
|
||||||
|
let currentProduct = null;
|
||||||
|
let currentSection = null;
|
||||||
|
let currentCategory = null;
|
||||||
|
let specsLines = [];
|
||||||
|
let totalRows = 0;
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
while (items.length < maxItems) {
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await fetchRows(offset, BATCH_SIZE);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` Fetch error at offset ${offset}: ${e.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) break;
|
||||||
|
totalRows += rows.length;
|
||||||
|
|
||||||
|
for (const line of rows) {
|
||||||
|
// Track sections
|
||||||
|
if (line.startsWith('# @CATEGORY')) {
|
||||||
|
currentSection = 'category';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @PRODUCT')) {
|
||||||
|
currentSection = 'product';
|
||||||
|
currentProduct = {};
|
||||||
|
specsLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @SPECS')) {
|
||||||
|
currentSection = 'specs';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @FILTERS')) {
|
||||||
|
currentSection = 'filters';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @ITEMS')) {
|
||||||
|
currentSection = 'items';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @IMAGES')) {
|
||||||
|
currentSection = 'images';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '---' || line.startsWith('# DISCLAIMER')) {
|
||||||
|
// End of product — process if we have one
|
||||||
|
if (currentProduct && currentProduct.name) {
|
||||||
|
const dims = parseDimensions(specsLines);
|
||||||
|
if (dims && dims.width > 0.1 && dims.height > 0.1) {
|
||||||
|
const category = guessCategory(currentProduct.name, currentCategory);
|
||||||
|
const id = slugify(currentProduct.name);
|
||||||
|
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
seenIds.add(id);
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
name: currentProduct.name,
|
||||||
|
ikeaSeries: extractSeries(currentProduct.name),
|
||||||
|
sku: currentProduct.sku || null,
|
||||||
|
category,
|
||||||
|
rooms: ROOM_MAP[category] || [],
|
||||||
|
dimensions: dims,
|
||||||
|
mesh: generateMesh(dims, category)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length >= maxItems) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentProduct = null;
|
||||||
|
currentSection = line.startsWith('# DISCLAIMER') ? 'disclaimer' : null;
|
||||||
|
specsLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse line content based on section
|
||||||
|
if (currentSection === 'category') {
|
||||||
|
const nameMatch = line.match(/^Name:\s*(.+)/);
|
||||||
|
if (nameMatch) currentCategory = nameMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'product' && currentProduct) {
|
||||||
|
const nameMatch = line.match(/^Name:\s*(.+)/);
|
||||||
|
if (nameMatch) currentProduct.name = nameMatch[1].trim();
|
||||||
|
const skuMatch = line.match(/^SKU:\s*(.+)/);
|
||||||
|
if (skuMatch) currentProduct.sku = skuMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'specs') {
|
||||||
|
if (line.trim()) specsLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length >= maxItems) break;
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
|
||||||
|
// Safety limit: don't scan more than 100k rows
|
||||||
|
if (offset > 100000) {
|
||||||
|
console.error(` Reached scan limit at ${offset} rows`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(` Scanned ${totalRows} rows, extracted ${items.length} items with dimensions`);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let limit = 100;
|
||||||
|
let outputPath = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--limit' && args[i + 1]) limit = parseInt(args[i + 1], 10);
|
||||||
|
if (args[i] === '--output' && args[i + 1]) outputPath = args[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await importDataset(limit);
|
||||||
|
|
||||||
|
const catalog = {
|
||||||
|
version: '1.0',
|
||||||
|
source: 'huggingface-ikea-us-commercetxt',
|
||||||
|
units: 'meters',
|
||||||
|
description: `Imported from HuggingFace dataset tsazan/ikea-us-commercetxt (${items.length} items)`,
|
||||||
|
categories: [...new Set(items.map(i => i.category))].sort(),
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(catalog, null, 2);
|
||||||
|
|
||||||
|
if (outputPath) {
|
||||||
|
const fs = await import('fs');
|
||||||
|
fs.writeFileSync(outputPath, json);
|
||||||
|
console.error(`Wrote ${items.length} items to ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('Error:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
545
src/catalog.js
Normal file
545
src/catalog.js
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
/**
|
||||||
|
* CatalogPanel — left sidebar for browsing furniture catalog.
|
||||||
|
*
|
||||||
|
* Shows source tabs (All / Standard / IKEA), categories, series filter,
|
||||||
|
* 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.selectedSource = 'all'; // 'all', 'standard', 'ikea'
|
||||||
|
this.selectedCategory = 'all';
|
||||||
|
this.selectedSeries = 'all';
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.selectedRoomId = null;
|
||||||
|
|
||||||
|
this._build();
|
||||||
|
this._bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Build DOM ----
|
||||||
|
|
||||||
|
_build() {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.container.className = 'catalog-panel';
|
||||||
|
|
||||||
|
// Source tabs
|
||||||
|
this._sourceBar = document.createElement('div');
|
||||||
|
this._sourceBar.className = 'catalog-source-tabs';
|
||||||
|
this.container.appendChild(this._sourceBar);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Series filter (IKEA only, hidden by default)
|
||||||
|
this._seriesBar = document.createElement('div');
|
||||||
|
this._seriesBar.className = 'catalog-series';
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
this.container.appendChild(this._seriesBar);
|
||||||
|
|
||||||
|
// Items list
|
||||||
|
this._itemList = document.createElement('div');
|
||||||
|
this._itemList.className = 'catalog-items';
|
||||||
|
this.container.appendChild(this._itemList);
|
||||||
|
|
||||||
|
// Create custom button
|
||||||
|
this._createBtn = document.createElement('button');
|
||||||
|
this._createBtn.className = 'catalog-create-btn';
|
||||||
|
this._createBtn.textContent = '+ Create Custom Furniture';
|
||||||
|
this._createBtn.addEventListener('click', () => this._showCreateForm());
|
||||||
|
this.container.appendChild(this._createBtn);
|
||||||
|
|
||||||
|
// Create form container (hidden initially)
|
||||||
|
this._createFormContainer = document.createElement('div');
|
||||||
|
this._createFormContainer.className = 'catalog-create-form';
|
||||||
|
this._createFormContainer.style.display = 'none';
|
||||||
|
this.container.appendChild(this._createFormContainer);
|
||||||
|
|
||||||
|
this._renderSourceTabs();
|
||||||
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSourceTabs() {
|
||||||
|
this._sourceBar.innerHTML = '';
|
||||||
|
const hasIkea = this._hasIkeaItems();
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'standard', label: 'Standard' },
|
||||||
|
];
|
||||||
|
if (hasIkea) {
|
||||||
|
sources.push({ id: 'ikea', label: 'IKEA' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const src of sources) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'catalog-source-btn' + (src.id === this.selectedSource ? ' active' : '');
|
||||||
|
btn.textContent = src.label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.selectedSource = src.id;
|
||||||
|
this.selectedSeries = 'all';
|
||||||
|
this._renderSourceTabs();
|
||||||
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
this._sourceBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item count badge
|
||||||
|
const count = this._getFilteredItems().length;
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'catalog-count';
|
||||||
|
badge.textContent = count;
|
||||||
|
this._sourceBar.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCategories() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return;
|
||||||
|
|
||||||
|
this._categoryBar.innerHTML = '';
|
||||||
|
|
||||||
|
// Get categories from filtered items
|
||||||
|
const items = this._getSourceFilteredItems();
|
||||||
|
const activeCats = new Set(items.map(it => it.category));
|
||||||
|
const categories = ['all', ...catalog.categories.filter(c => activeCats.has(c))];
|
||||||
|
|
||||||
|
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._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
this._categoryBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSeriesFilter() {
|
||||||
|
// Only show series filter when IKEA source is active
|
||||||
|
if (this.selectedSource !== 'ikea') {
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this._getSourceFilteredItems();
|
||||||
|
const seriesSet = new Set();
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.ikeaSeries) seriesSet.add(it.ikeaSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesSet.size < 2) {
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._seriesBar.style.display = '';
|
||||||
|
this._seriesBar.innerHTML = '';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'catalog-series-label';
|
||||||
|
label.textContent = 'Series:';
|
||||||
|
this._seriesBar.appendChild(label);
|
||||||
|
|
||||||
|
const seriesList = ['all', ...Array.from(seriesSet).sort()];
|
||||||
|
for (const s of seriesList) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'catalog-series-btn' + (s === this.selectedSeries ? ' active' : '');
|
||||||
|
btn.textContent = s === 'all' ? 'All' : s;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.selectedSeries = s;
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
this._seriesBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasIkeaItems() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return false;
|
||||||
|
return catalog.items.some(it => it.id.startsWith('ikea-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get items filtered by source tab only */
|
||||||
|
_getSourceFilteredItems() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return [];
|
||||||
|
|
||||||
|
let items = catalog.items;
|
||||||
|
if (this.selectedSource === 'ikea') {
|
||||||
|
items = items.filter(it => it.id.startsWith('ikea-'));
|
||||||
|
} else if (this.selectedSource === 'standard') {
|
||||||
|
items = items.filter(it => !it.id.startsWith('ikea-'));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get items with all filters applied */
|
||||||
|
_getFilteredItems() {
|
||||||
|
let items = this._getSourceFilteredItems();
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (this.selectedCategory !== 'all') {
|
||||||
|
items = items.filter(it => it.category === this.selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by series (IKEA only)
|
||||||
|
if (this.selectedSeries !== 'all') {
|
||||||
|
items = items.filter(it => it.ikeaSeries === this.selectedSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) ||
|
||||||
|
(it.ikeaSeries && it.ikeaSeries.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderItems() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) {
|
||||||
|
this._itemList.innerHTML = '<div class="catalog-empty">No catalog loaded</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this._getFilteredItems();
|
||||||
|
|
||||||
|
// Update count badge
|
||||||
|
const badge = this._sourceBar.querySelector('.catalog-count');
|
||||||
|
if (badge) badge.textContent = items.length;
|
||||||
|
|
||||||
|
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}\u00d7${dims.depth}\u00d7${dims.height}m`;
|
||||||
|
|
||||||
|
// Add IKEA badge for IKEA items
|
||||||
|
const isIkea = item.id.startsWith('ikea-');
|
||||||
|
const badge = isIkea ? `<span class="catalog-item-badge">IKEA</span>` : '';
|
||||||
|
const series = isIkea && item.ikeaSeries ? `<span class="catalog-item-series">${item.ikeaSeries}</span>` : '';
|
||||||
|
|
||||||
|
card.innerHTML =
|
||||||
|
`<div class="catalog-item-swatch" style="background:${color}"></div>` +
|
||||||
|
`<div class="catalog-item-info">` +
|
||||||
|
`<div class="catalog-item-name">${badge}${item.name}</div>` +
|
||||||
|
`<div class="catalog-item-dims">${dimStr}${series}</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Custom furniture creator ----
|
||||||
|
|
||||||
|
_showCreateForm() {
|
||||||
|
this._createBtn.style.display = 'none';
|
||||||
|
this._createFormContainer.style.display = '';
|
||||||
|
this._buildCreateForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideCreateForm() {
|
||||||
|
this._createBtn.style.display = '';
|
||||||
|
this._createFormContainer.style.display = 'none';
|
||||||
|
this._createFormContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCreateForm() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
const categories = catalog?.categories || [];
|
||||||
|
const f = this._createFormContainer;
|
||||||
|
f.innerHTML = '';
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameLabel = document.createElement('label');
|
||||||
|
nameLabel.textContent = 'Name';
|
||||||
|
f.appendChild(nameLabel);
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.placeholder = 'e.g. Custom Shelf';
|
||||||
|
f.appendChild(nameInput);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const dimLabel = document.createElement('label');
|
||||||
|
dimLabel.textContent = 'Dimensions (meters)';
|
||||||
|
f.appendChild(dimLabel);
|
||||||
|
const dimRow = document.createElement('div');
|
||||||
|
dimRow.className = 'catalog-create-dims';
|
||||||
|
|
||||||
|
const makeDimField = (label, value) => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = label;
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'number';
|
||||||
|
inp.step = '0.05';
|
||||||
|
inp.min = '0.05';
|
||||||
|
inp.max = '10';
|
||||||
|
inp.value = value;
|
||||||
|
wrap.appendChild(inp);
|
||||||
|
dimRow.appendChild(wrap);
|
||||||
|
return inp;
|
||||||
|
};
|
||||||
|
|
||||||
|
const widthInput = makeDimField('W', '0.8');
|
||||||
|
const depthInput = makeDimField('D', '0.4');
|
||||||
|
const heightInput = makeDimField('H', '0.8');
|
||||||
|
f.appendChild(dimRow);
|
||||||
|
|
||||||
|
// Color
|
||||||
|
const colorLabel = document.createElement('label');
|
||||||
|
colorLabel.textContent = 'Color';
|
||||||
|
f.appendChild(colorLabel);
|
||||||
|
const colorRow = document.createElement('div');
|
||||||
|
colorRow.className = 'catalog-create-color-row';
|
||||||
|
const colorPicker = document.createElement('input');
|
||||||
|
colorPicker.type = 'color';
|
||||||
|
colorPicker.value = '#8899aa';
|
||||||
|
const colorText = document.createElement('input');
|
||||||
|
colorText.type = 'text';
|
||||||
|
colorText.value = '#8899aa';
|
||||||
|
colorPicker.addEventListener('input', () => { colorText.value = colorPicker.value; });
|
||||||
|
colorText.addEventListener('input', () => {
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(colorText.value)) colorPicker.value = colorText.value;
|
||||||
|
});
|
||||||
|
colorRow.appendChild(colorPicker);
|
||||||
|
colorRow.appendChild(colorText);
|
||||||
|
f.appendChild(colorRow);
|
||||||
|
|
||||||
|
// Category
|
||||||
|
const catLabel = document.createElement('label');
|
||||||
|
catLabel.textContent = 'Category';
|
||||||
|
f.appendChild(catLabel);
|
||||||
|
const catSelect = document.createElement('select');
|
||||||
|
for (const cat of categories) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||||
|
catSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
f.appendChild(catSelect);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'catalog-create-actions';
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'btn-cancel';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', () => this._hideCreateForm());
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.className = 'btn-submit';
|
||||||
|
submitBtn.textContent = 'Add to Catalog';
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { nameInput.focus(); return; }
|
||||||
|
const w = parseFloat(widthInput.value) || 0.8;
|
||||||
|
const d = parseFloat(depthInput.value) || 0.4;
|
||||||
|
const h = parseFloat(heightInput.value) || 0.8;
|
||||||
|
const color = colorText.value || '#8899aa';
|
||||||
|
const category = catSelect.value;
|
||||||
|
this._addCustomItem({ name, width: w, depth: d, height: h, color, category });
|
||||||
|
this._hideCreateForm();
|
||||||
|
});
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
f.appendChild(actions);
|
||||||
|
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addCustomItem({ name, width, depth, height, color, category }) {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return;
|
||||||
|
|
||||||
|
// Generate unique id
|
||||||
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
let id = `custom-${slug}`;
|
||||||
|
let n = 1;
|
||||||
|
while (this.renderer._catalogIndex.has(id)) {
|
||||||
|
id = `custom-${slug}-${++n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple box mesh matching the catalog format
|
||||||
|
const item = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
rooms: [],
|
||||||
|
dimensions: { width, depth, height },
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
geometry: 'box',
|
||||||
|
size: [width, height, depth],
|
||||||
|
position: [0, height / 2, 0],
|
||||||
|
color
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
catalog.items.push(item);
|
||||||
|
this.renderer._catalogIndex.set(id, item);
|
||||||
|
|
||||||
|
// Refresh display
|
||||||
|
this._renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 full panel (e.g., after catalog merge or floor change). */
|
||||||
|
refresh() {
|
||||||
|
this._renderSourceTabs();
|
||||||
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
754
src/floorplan-import.js
Normal file
754
src/floorplan-import.js
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
/**
|
||||||
|
* FloorplanImporter - Analyzes floor plan images using LLM vision APIs
|
||||||
|
* and converts them into house JSON for the 3D viewer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude (Anthropic)',
|
||||||
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt, apiKey) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.content[0].text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (GPT-4o)',
|
||||||
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt, apiKey) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64Image}` } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a floor plan analyzer. Given an image of a floor plan or floor layout,
|
||||||
|
extract the room structure and output valid JSON matching the exact schema below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All dimensions in meters. Use standard architectural conventions if no scale bar
|
||||||
|
is visible (standard interior door = 0.9m wide, entry door = 1.0-1.1m)
|
||||||
|
- Rooms are axis-aligned rectangles positioned on a coordinate grid
|
||||||
|
- Position {x, y} is the room's bottom-left corner (x = west-east, y = south-north)
|
||||||
|
- Each room has walls on 4 cardinal directions (north, south, east, west)
|
||||||
|
- Walls are "exterior" if they face outside the building, "interior" otherwise
|
||||||
|
- Doors have: id, type (entry|interior|patio|open), position (meters from wall start),
|
||||||
|
width, height, connectsTo (adjacent room id or "exterior")
|
||||||
|
- Windows have: id, type (casement|fixed), position, width, height, sillHeight
|
||||||
|
- Generate unique IDs: "{floorId}-{roomSlug}" for rooms, "{roomId}-d{n}" for doors,
|
||||||
|
"{roomId}-w{n}" for windows
|
||||||
|
- Room types: living, kitchen, dining, bedroom, bathroom, hallway, office, utility,
|
||||||
|
storage, laundry, garage
|
||||||
|
- Flooring: "tile" for kitchen/bathroom/utility/hallway, "hardwood" for others
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences, no explanation.`;
|
||||||
|
|
||||||
|
export class FloorplanImporter {
|
||||||
|
constructor(renderer, options = {}) {
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.onHouseLoaded = options.onHouseLoaded || null;
|
||||||
|
this._overlay = null;
|
||||||
|
this._imageFile = null;
|
||||||
|
this._imagePreviewData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (this._overlay) return;
|
||||||
|
this._overlay = this._buildModal();
|
||||||
|
document.body.appendChild(this._overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this._overlay) {
|
||||||
|
this._overlay.remove();
|
||||||
|
this._overlay = null;
|
||||||
|
}
|
||||||
|
this._imageFile = null;
|
||||||
|
this._imagePreviewData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'fp-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.fp-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
.fp-modal {
|
||||||
|
background: rgba(255,255,255,0.97); border-radius: 8px;
|
||||||
|
width: 520px; max-height: 90vh; overflow-y: auto;
|
||||||
|
padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.fp-modal h2 { font-size: 16px; margin: 0 0 16px; color: #333; }
|
||||||
|
.fp-drop-zone {
|
||||||
|
border: 2px dashed #ccc; border-radius: 6px;
|
||||||
|
padding: 32px; text-align: center; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
color: #888; font-size: 13px; position: relative;
|
||||||
|
min-height: 120px; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.fp-drop-zone.dragover { border-color: #4a90d9; background: #e8f0fe; }
|
||||||
|
.fp-drop-zone img {
|
||||||
|
max-width: 100%; max-height: 200px; border-radius: 4px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fp-drop-zone input[type="file"] {
|
||||||
|
position: absolute; inset: 0; opacity: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.fp-field { margin-top: 12px; }
|
||||||
|
.fp-field label {
|
||||||
|
display: block; font-size: 11px; color: #666;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fp-field input, .fp-field select, .fp-field textarea {
|
||||||
|
width: 100%; padding: 6px 10px; border: 1px solid #ccc;
|
||||||
|
border-radius: 4px; font-size: 13px; outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.fp-field input:focus, .fp-field select:focus, .fp-field textarea:focus {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.fp-row { display: flex; gap: 12px; }
|
||||||
|
.fp-row .fp-field { flex: 1; }
|
||||||
|
.fp-api-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.fp-api-row .fp-field:first-child { width: 160px; flex: none; }
|
||||||
|
.fp-api-row .fp-field:last-child { flex: 1; }
|
||||||
|
.fp-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||||
|
.fp-btn {
|
||||||
|
padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
font-size: 13px; cursor: pointer; background: #fff;
|
||||||
|
}
|
||||||
|
.fp-btn:hover { background: #f0f0f0; }
|
||||||
|
.fp-btn-primary {
|
||||||
|
background: #4a90d9; color: #fff; border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.fp-btn-primary:hover { background: #3a7bc8; }
|
||||||
|
.fp-btn-primary:disabled {
|
||||||
|
background: #a0c4e8; border-color: #a0c4e8; cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.fp-btn-danger { color: #c44; }
|
||||||
|
.fp-btn-danger:hover { background: #fdd; }
|
||||||
|
.fp-status {
|
||||||
|
margin-top: 12px; font-size: 12px; color: #666;
|
||||||
|
display: none; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.fp-status.visible { display: flex; }
|
||||||
|
.fp-spinner {
|
||||||
|
width: 16px; height: 16px; border: 2px solid #ccc;
|
||||||
|
border-top-color: #4a90d9; border-radius: 50%;
|
||||||
|
animation: fp-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes fp-spin { to { transform: rotate(360deg); } }
|
||||||
|
.fp-error { color: #c44; font-size: 12px; margin-top: 8px; }
|
||||||
|
.fp-preview { margin-top: 16px; display: none; }
|
||||||
|
.fp-preview.visible { display: block; }
|
||||||
|
.fp-preview h3 { font-size: 14px; margin: 0 0 8px; color: #333; }
|
||||||
|
.fp-preview-summary {
|
||||||
|
font-size: 12px; color: #555; margin-bottom: 10px;
|
||||||
|
padding: 8px; background: #f5f5f5; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fp-room-list {
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
border: 1px solid #eee; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fp-room-item {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
padding: 6px 10px; font-size: 12px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.fp-room-item:last-child { border-bottom: none; }
|
||||||
|
.fp-room-dims { color: #888; }
|
||||||
|
.fp-json-edit {
|
||||||
|
display: none; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.fp-json-edit.visible { display: block; }
|
||||||
|
.fp-json-edit textarea {
|
||||||
|
width: 100%; height: 300px; font-family: monospace;
|
||||||
|
font-size: 11px; white-space: pre; tab-size: 2;
|
||||||
|
}
|
||||||
|
.fp-clear-key {
|
||||||
|
font-size: 11px; color: #888; cursor: pointer;
|
||||||
|
text-decoration: underline; margin-left: 4px;
|
||||||
|
}
|
||||||
|
.fp-clear-key:hover { color: #c44; }
|
||||||
|
</style>
|
||||||
|
<div class="fp-modal">
|
||||||
|
<h2>Import Floor Plan</h2>
|
||||||
|
|
||||||
|
<div class="fp-drop-zone" id="fp-drop-zone">
|
||||||
|
<input type="file" accept="image/png,image/jpeg,image/webp" id="fp-file-input">
|
||||||
|
<div id="fp-drop-label">Drop image here or click to browse<br><small>PNG, JPG, WebP</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-row">
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>Building Name</label>
|
||||||
|
<input type="text" id="fp-name" value="Imported Floor Plan" placeholder="My House">
|
||||||
|
</div>
|
||||||
|
<div class="fp-field" style="width:100px;flex:none">
|
||||||
|
<label>Floors Shown</label>
|
||||||
|
<select id="fp-floors">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>Scale Hint (optional)</label>
|
||||||
|
<input type="text" id="fp-scale" placeholder="e.g. The living room is about 5m wide">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-api-row">
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>API Provider</label>
|
||||||
|
<select id="fp-provider">
|
||||||
|
<option value="claude">Claude (Anthropic)</option>
|
||||||
|
<option value="openai">OpenAI (GPT-4o)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>API Key <span class="fp-clear-key" id="fp-clear-key">clear saved</span></label>
|
||||||
|
<input type="password" id="fp-api-key" placeholder="Enter API key">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-status" id="fp-status">
|
||||||
|
<div class="fp-spinner"></div>
|
||||||
|
<span id="fp-status-text">Analyzing floor plan...</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp-error" id="fp-error"></div>
|
||||||
|
|
||||||
|
<div class="fp-actions" id="fp-actions-main">
|
||||||
|
<button class="fp-btn fp-btn-primary" id="fp-analyze" disabled>Analyze Floor Plan</button>
|
||||||
|
<button class="fp-btn" id="fp-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-preview" id="fp-preview">
|
||||||
|
<h3>Result Preview</h3>
|
||||||
|
<div class="fp-preview-summary" id="fp-summary"></div>
|
||||||
|
<div class="fp-room-list" id="fp-room-list"></div>
|
||||||
|
|
||||||
|
<div class="fp-json-edit" id="fp-json-edit">
|
||||||
|
<textarea id="fp-json-textarea"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-actions" style="margin-top:12px">
|
||||||
|
<button class="fp-btn fp-btn-primary" id="fp-accept">Accept & Load</button>
|
||||||
|
<button class="fp-btn" id="fp-edit-json">Edit JSON</button>
|
||||||
|
<button class="fp-btn" id="fp-reanalyze">Re-analyze</button>
|
||||||
|
<button class="fp-btn fp-btn-danger" id="fp-cancel2">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire up events after inserting into DOM
|
||||||
|
requestAnimationFrame(() => this._wireEvents(overlay));
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireEvents(overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
|
||||||
|
// Close on overlay background click
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input / drag-drop
|
||||||
|
const dropZone = $('fp-drop-zone');
|
||||||
|
const fileInput = $('fp-file-input');
|
||||||
|
const dropLabel = $('fp-drop-label');
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
this._handleImageUpload(file, dropZone, dropLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) this._handleImageUpload(file, dropZone, dropLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved API key
|
||||||
|
const providerSelect = $('fp-provider');
|
||||||
|
const apiKeyInput = $('fp-api-key');
|
||||||
|
const loadSavedKey = () => {
|
||||||
|
const saved = localStorage.getItem(`floorplan-api-key-${providerSelect.value}`);
|
||||||
|
apiKeyInput.value = saved || '';
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
};
|
||||||
|
providerSelect.addEventListener('change', loadSavedKey);
|
||||||
|
loadSavedKey();
|
||||||
|
|
||||||
|
// Save key on input
|
||||||
|
apiKeyInput.addEventListener('input', () => {
|
||||||
|
const key = apiKeyInput.value.trim();
|
||||||
|
if (key) {
|
||||||
|
localStorage.setItem(`floorplan-api-key-${providerSelect.value}`, key);
|
||||||
|
}
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear saved key
|
||||||
|
$('fp-clear-key').addEventListener('click', () => {
|
||||||
|
localStorage.removeItem(`floorplan-api-key-${providerSelect.value}`);
|
||||||
|
apiKeyInput.value = '';
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyze button
|
||||||
|
$('fp-analyze').addEventListener('click', () => this._doAnalyze(overlay));
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
$('fp-cancel').addEventListener('click', () => this.close());
|
||||||
|
$('fp-cancel2').addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// Accept
|
||||||
|
$('fp-accept').addEventListener('click', () => {
|
||||||
|
const jsonEdit = $('fp-json-edit');
|
||||||
|
let data = this._resultData;
|
||||||
|
if (jsonEdit.classList.contains('visible')) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse($('fp-json-textarea').value);
|
||||||
|
} catch (e) {
|
||||||
|
$('fp-error').textContent = 'Invalid JSON: ' + e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._applyToRenderer(data);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit JSON toggle
|
||||||
|
$('fp-edit-json').addEventListener('click', () => {
|
||||||
|
const jsonEdit = $('fp-json-edit');
|
||||||
|
const btn = $('fp-edit-json');
|
||||||
|
if (jsonEdit.classList.contains('visible')) {
|
||||||
|
jsonEdit.classList.remove('visible');
|
||||||
|
btn.textContent = 'Edit JSON';
|
||||||
|
} else {
|
||||||
|
$('fp-json-textarea').value = JSON.stringify(this._resultData, null, 2);
|
||||||
|
jsonEdit.classList.add('visible');
|
||||||
|
btn.textContent = 'Hide JSON';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-analyze
|
||||||
|
$('fp-reanalyze').addEventListener('click', () => {
|
||||||
|
$('fp-preview').classList.remove('visible');
|
||||||
|
$('fp-json-edit').classList.remove('visible');
|
||||||
|
$('fp-actions-main').style.display = 'flex';
|
||||||
|
$('fp-error').textContent = '';
|
||||||
|
this._doAnalyze(overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleImageUpload(file, dropZone, dropLabel) {
|
||||||
|
this._imageFile = file;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
// Show image preview in the drop zone
|
||||||
|
dropLabel.innerHTML = '';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = e.target.result;
|
||||||
|
dropLabel.appendChild(img);
|
||||||
|
const info = document.createElement('small');
|
||||||
|
info.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
||||||
|
info.style.color = '#888';
|
||||||
|
dropLabel.appendChild(info);
|
||||||
|
this._updateAnalyzeButton(this._overlay);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateAnalyzeButton(overlay) {
|
||||||
|
const btn = overlay.querySelector('#fp-analyze');
|
||||||
|
const apiKey = overlay.querySelector('#fp-api-key').value.trim();
|
||||||
|
btn.disabled = !this._imageFile || !apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _preprocessImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const maxDim = 2048;
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
const mediaType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = mediaType === 'image/jpeg' ? 0.9 : undefined;
|
||||||
|
const base64 = canvas.toDataURL(mediaType, quality).split(',')[1];
|
||||||
|
resolve({ base64, mediaType, width, height });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildPrompt(name, floorCount, scaleHint) {
|
||||||
|
let prompt = `Analyze this floor plan image. The building is named "${name}".\n`;
|
||||||
|
if (scaleHint) {
|
||||||
|
prompt += `Scale reference: ${scaleHint}\n`;
|
||||||
|
} else {
|
||||||
|
prompt += `Estimate dimensions from standard door widths.\n`;
|
||||||
|
}
|
||||||
|
prompt += `This image shows ${floorCount} floor(s).\n\n`;
|
||||||
|
prompt += `Output the house JSON with this structure:
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": <number>, "depth": <number> },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "gable"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-room-slug",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"type": "living|kitchen|...",
|
||||||
|
"position": { "x": <meters>, "y": <meters> },
|
||||||
|
"dimensions": { "width": <meters>, "length": <meters> },
|
||||||
|
"flooring": "tile|hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": { "type": "exterior|interior", "doors": [...], "windows": [...] },
|
||||||
|
"north": { ... },
|
||||||
|
"east": { ... },
|
||||||
|
"west": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _analyzeWithLLM(base64Image, mediaType, overlay) {
|
||||||
|
const provider = overlay.querySelector('#fp-provider').value;
|
||||||
|
const apiKey = overlay.querySelector('#fp-api-key').value.trim();
|
||||||
|
const name = overlay.querySelector('#fp-name').value.trim() || 'Imported Floor Plan';
|
||||||
|
const floorCount = parseInt(overlay.querySelector('#fp-floors').value);
|
||||||
|
const scaleHint = overlay.querySelector('#fp-scale').value.trim();
|
||||||
|
|
||||||
|
const providerConfig = API_PROVIDERS[provider];
|
||||||
|
const userPrompt = this._buildPrompt(name, floorCount, scaleHint);
|
||||||
|
const reqOptions = providerConfig.buildRequest(base64Image, mediaType, SYSTEM_PROMPT, userPrompt, apiKey);
|
||||||
|
|
||||||
|
const response = await fetch(providerConfig.endpoint, reqOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody = await response.text();
|
||||||
|
throw new Error(`API error (${response.status}): ${errBody}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const jsonText = providerConfig.extractJSON(data);
|
||||||
|
return jsonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doAnalyze(overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
const statusEl = $('fp-status');
|
||||||
|
const statusText = $('fp-status-text');
|
||||||
|
const errorEl = $('fp-error');
|
||||||
|
const analyzeBtn = $('fp-analyze');
|
||||||
|
|
||||||
|
errorEl.textContent = '';
|
||||||
|
statusEl.classList.add('visible');
|
||||||
|
statusText.textContent = 'Preprocessing image...';
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { base64, mediaType } = await this._preprocessImage(this._imageFile);
|
||||||
|
|
||||||
|
statusText.textContent = 'Analyzing floor plan with AI...';
|
||||||
|
|
||||||
|
const jsonText = await this._analyzeWithLLM(base64, mediaType, overlay);
|
||||||
|
|
||||||
|
statusText.textContent = 'Parsing result...';
|
||||||
|
|
||||||
|
// Strip markdown fences if present
|
||||||
|
let cleaned = jsonText.trim();
|
||||||
|
if (cleaned.startsWith('```')) {
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let houseData;
|
||||||
|
try {
|
||||||
|
houseData = JSON.parse(cleaned);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to parse LLM response as JSON: ${e.message}\n\nRaw response:\n${jsonText.substring(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-repair common issues
|
||||||
|
houseData = this._autoRepair(houseData);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const { valid, errors } = this._validateHouseJSON(houseData);
|
||||||
|
if (!valid) {
|
||||||
|
console.warn('Validation warnings:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._resultData = houseData;
|
||||||
|
this._showPreview(houseData, overlay);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
statusEl.classList.remove('visible');
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateHouseJSON(data) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!data.name) errors.push('Missing building name');
|
||||||
|
if (!data.building?.footprint) errors.push('Missing building footprint');
|
||||||
|
if (!data.floors?.length) errors.push('No floors found');
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.rooms?.length) {
|
||||||
|
errors.push(`Floor "${floor.name}" has no rooms`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
if (!room.id) errors.push('Room missing id');
|
||||||
|
if (!room.position) errors.push(`Room "${room.id}" missing position`);
|
||||||
|
if (!room.dimensions) errors.push(`Room "${room.id}" missing dimensions`);
|
||||||
|
if (!room.walls) errors.push(`Room "${room.id}" missing walls`);
|
||||||
|
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
const wall = room.walls?.[dir];
|
||||||
|
if (!wall) errors.push(`Room "${room.id}" missing ${dir} wall`);
|
||||||
|
if (wall && !['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
errors.push(`Room "${room.id}" ${dir} wall has invalid type "${wall.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
_autoRepair(data) {
|
||||||
|
if (!data.name) data.name = 'Imported Floor Plan';
|
||||||
|
if (!data.units) data.units = 'meters';
|
||||||
|
if (!data.building) data.building = {};
|
||||||
|
if (!data.building.footprint) {
|
||||||
|
// Compute from rooms
|
||||||
|
let maxX = 0, maxY = 0;
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
for (const room of (floor.rooms || [])) {
|
||||||
|
const rx = (parseFloat(room.position?.x) || 0) + (parseFloat(room.dimensions?.width) || 0);
|
||||||
|
const ry = (parseFloat(room.position?.y) || 0) + (parseFloat(room.dimensions?.length) || 0);
|
||||||
|
maxX = Math.max(maxX, rx);
|
||||||
|
maxY = Math.max(maxY, ry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.building.footprint = { width: maxX || 10, depth: maxY || 10 };
|
||||||
|
}
|
||||||
|
if (!data.building.wallThickness) data.building.wallThickness = 0.24;
|
||||||
|
if (!data.building.roofType) data.building.roofType = 'gable';
|
||||||
|
|
||||||
|
const tileTypes = new Set(['kitchen', 'bathroom', 'utility', 'hallway', 'laundry']);
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.id) floor.id = `f${floor.level || 0}`;
|
||||||
|
if (!floor.name) floor.name = floor.nameEN || `Floor ${floor.level || 0}`;
|
||||||
|
if (!floor.nameEN) floor.nameEN = floor.name;
|
||||||
|
if (floor.level === undefined) floor.level = 0;
|
||||||
|
if (!floor.ceilingHeight) floor.ceilingHeight = 2.6;
|
||||||
|
|
||||||
|
for (const room of (floor.rooms || [])) {
|
||||||
|
// Fix string numbers
|
||||||
|
if (room.position) {
|
||||||
|
room.position.x = parseFloat(room.position.x) || 0;
|
||||||
|
room.position.y = parseFloat(room.position.y) || 0;
|
||||||
|
} else {
|
||||||
|
room.position = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
if (room.dimensions) {
|
||||||
|
room.dimensions.width = parseFloat(room.dimensions.width) || 3;
|
||||||
|
room.dimensions.length = parseFloat(room.dimensions.length) || 3;
|
||||||
|
} else {
|
||||||
|
room.dimensions = { width: 3, length: 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.id) {
|
||||||
|
const slug = (room.nameEN || room.name || 'room').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
room.id = `${floor.id}-${slug}`;
|
||||||
|
}
|
||||||
|
if (!room.name) room.name = room.nameEN || room.id;
|
||||||
|
if (!room.nameEN) room.nameEN = room.name;
|
||||||
|
if (!room.type) room.type = 'living';
|
||||||
|
if (!room.flooring) {
|
||||||
|
room.flooring = tileTypes.has(room.type) ? 'tile' : 'hardwood';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure walls exist
|
||||||
|
if (!room.walls) room.walls = {};
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
if (!room.walls[dir]) {
|
||||||
|
room.walls[dir] = { type: 'interior' };
|
||||||
|
}
|
||||||
|
const wall = room.walls[dir];
|
||||||
|
if (!['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
wall.type = 'interior';
|
||||||
|
}
|
||||||
|
if (!wall.doors) wall.doors = [];
|
||||||
|
if (!wall.windows) wall.windows = [];
|
||||||
|
|
||||||
|
// Fix door/window numeric fields
|
||||||
|
for (const door of wall.doors) {
|
||||||
|
door.position = parseFloat(door.position) || 0;
|
||||||
|
door.width = parseFloat(door.width) || 0.9;
|
||||||
|
door.height = parseFloat(door.height) || 2.1;
|
||||||
|
}
|
||||||
|
for (const win of wall.windows) {
|
||||||
|
win.position = parseFloat(win.position) || 0;
|
||||||
|
win.width = parseFloat(win.width) || 1.2;
|
||||||
|
win.height = parseFloat(win.height) || 1.2;
|
||||||
|
if (win.sillHeight !== undefined) {
|
||||||
|
win.sillHeight = parseFloat(win.sillHeight) || 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_showPreview(houseData, overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
|
||||||
|
// Hide main actions, show preview
|
||||||
|
$('fp-actions-main').style.display = 'none';
|
||||||
|
$('fp-preview').classList.add('visible');
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
let totalRooms = 0, totalDoors = 0, totalWindows = 0;
|
||||||
|
for (const floor of houseData.floors) {
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
totalRooms++;
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
totalDoors += (room.walls[dir]?.doors?.length || 0);
|
||||||
|
totalWindows += (room.walls[dir]?.windows?.length || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('fp-summary').textContent =
|
||||||
|
`Found: ${totalRooms} rooms, ${totalDoors} doors, ${totalWindows} windows across ${houseData.floors.length} floor(s)`;
|
||||||
|
|
||||||
|
// Room list
|
||||||
|
const roomList = $('fp-room-list');
|
||||||
|
roomList.innerHTML = '';
|
||||||
|
for (const floor of houseData.floors) {
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
const w = room.dimensions.width;
|
||||||
|
const l = room.dimensions.length;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'fp-room-item';
|
||||||
|
item.innerHTML = `<span>${room.nameEN || room.name}</span><span class="fp-room-dims">${w.toFixed(1)} x ${l.toFixed(1)}m</span>`;
|
||||||
|
roomList.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyToRenderer(houseData) {
|
||||||
|
this.renderer.houseData = houseData;
|
||||||
|
this.renderer.currentFloor = 0;
|
||||||
|
this.renderer._clearFloor();
|
||||||
|
|
||||||
|
const floor = houseData.floors[0];
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this.renderer._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for UI to rebuild
|
||||||
|
this.renderer.container.dispatchEvent(new CustomEvent('houseloaded', {
|
||||||
|
detail: { name: houseData.name, floors: houseData.floors.length }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.onHouseLoaded) {
|
||||||
|
this.onHouseLoaded(houseData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
681
src/house-editor.js
Normal file
681
src/house-editor.js
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
/**
|
||||||
|
* HouseEditor — UI panel for house customization.
|
||||||
|
*
|
||||||
|
* Provides controls to create/edit houses, add/remove rooms,
|
||||||
|
* adjust dimensions, manage floors, and save as templates.
|
||||||
|
*/
|
||||||
|
export class HouseEditor {
|
||||||
|
constructor(container, { renderer, onHouseChanged }) {
|
||||||
|
this.container = container;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.onHouseChanged = onHouseChanged || (() => {});
|
||||||
|
this._editing = false;
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
get houseData() {
|
||||||
|
return this.renderer.houseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.houseData) {
|
||||||
|
this.container.innerHTML = '<p style="color:#999;font-size:12px;">No house loaded</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle button
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'he-toggle-btn';
|
||||||
|
toggleBtn.textContent = this._editing ? 'Close Editor' : 'Edit House';
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
this._editing = !this._editing;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.container.appendChild(toggleBtn);
|
||||||
|
|
||||||
|
if (!this._editing) return;
|
||||||
|
|
||||||
|
// House metadata section
|
||||||
|
this._renderMetadataSection();
|
||||||
|
|
||||||
|
// Building section
|
||||||
|
this._renderBuildingSection();
|
||||||
|
|
||||||
|
// Floor management
|
||||||
|
this._renderFloorSection();
|
||||||
|
|
||||||
|
// Room list with editing
|
||||||
|
this._renderRoomSection();
|
||||||
|
|
||||||
|
// Room editor (if a room is selected)
|
||||||
|
if (this._selectedRoomId) {
|
||||||
|
this._renderRoomEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save as template
|
||||||
|
this._renderSaveSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRoom(roomId) {
|
||||||
|
this._selectedRoomId = roomId;
|
||||||
|
if (this._editing) {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Metadata Section ----
|
||||||
|
|
||||||
|
_renderMetadataSection() {
|
||||||
|
const section = this._createSection('House Info');
|
||||||
|
|
||||||
|
const nameRow = this._createFieldRow('Name');
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.value = this.houseData.name || '';
|
||||||
|
nameInput.addEventListener('change', () => {
|
||||||
|
this.houseData.name = nameInput.value;
|
||||||
|
this.onHouseChanged('name');
|
||||||
|
});
|
||||||
|
nameRow.appendChild(nameInput);
|
||||||
|
section.appendChild(nameRow);
|
||||||
|
|
||||||
|
const descRow = this._createFieldRow('Description');
|
||||||
|
const descInput = document.createElement('input');
|
||||||
|
descInput.className = 'he-input';
|
||||||
|
descInput.value = this.houseData.description || '';
|
||||||
|
descInput.addEventListener('change', () => {
|
||||||
|
this.houseData.description = descInput.value;
|
||||||
|
this.onHouseChanged('description');
|
||||||
|
});
|
||||||
|
descRow.appendChild(descInput);
|
||||||
|
section.appendChild(descRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Building Section ----
|
||||||
|
|
||||||
|
_renderBuildingSection() {
|
||||||
|
const section = this._createSection('Building');
|
||||||
|
const building = this.houseData.building || {};
|
||||||
|
const footprint = building.footprint || {};
|
||||||
|
|
||||||
|
const widthRow = this._createFieldRow('Width (m)');
|
||||||
|
const widthInput = this._createNumberInput(footprint.width || 12, 4, 30, 0.5, (val) => {
|
||||||
|
if (!this.houseData.building) this.houseData.building = {};
|
||||||
|
if (!this.houseData.building.footprint) this.houseData.building.footprint = {};
|
||||||
|
this.houseData.building.footprint.width = val;
|
||||||
|
this.onHouseChanged('building');
|
||||||
|
});
|
||||||
|
widthRow.appendChild(widthInput);
|
||||||
|
section.appendChild(widthRow);
|
||||||
|
|
||||||
|
const depthRow = this._createFieldRow('Depth (m)');
|
||||||
|
const depthInput = this._createNumberInput(footprint.depth || 10, 4, 30, 0.5, (val) => {
|
||||||
|
if (!this.houseData.building) this.houseData.building = {};
|
||||||
|
if (!this.houseData.building.footprint) this.houseData.building.footprint = {};
|
||||||
|
this.houseData.building.footprint.depth = val;
|
||||||
|
this.onHouseChanged('building');
|
||||||
|
});
|
||||||
|
depthRow.appendChild(depthInput);
|
||||||
|
section.appendChild(depthRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Floor Section ----
|
||||||
|
|
||||||
|
_renderFloorSection() {
|
||||||
|
const section = this._createSection('Floors');
|
||||||
|
|
||||||
|
const floors = this.houseData.floors || [];
|
||||||
|
for (let i = 0; i < floors.length; i++) {
|
||||||
|
const floor = floors[i];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-floor-row';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'he-floor-label';
|
||||||
|
label.textContent = `${floor.name} (${floor.rooms.length} rooms)`;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
// Ceiling height
|
||||||
|
const heightInput = this._createNumberInput(floor.ceilingHeight || 2.5, 2.2, 4.0, 0.1, (val) => {
|
||||||
|
floor.ceilingHeight = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
heightInput.title = 'Ceiling height';
|
||||||
|
heightInput.style.width = '55px';
|
||||||
|
row.appendChild(heightInput);
|
||||||
|
|
||||||
|
// Remove floor button (only if more than 1 floor)
|
||||||
|
if (floors.length > 1) {
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'he-icon-btn he-icon-btn-danger';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.title = 'Remove floor';
|
||||||
|
removeBtn.addEventListener('click', () => this._removeFloor(i));
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'he-add-btn';
|
||||||
|
addBtn.textContent = '+ Add Floor';
|
||||||
|
addBtn.addEventListener('click', () => this._addFloor());
|
||||||
|
section.appendChild(addBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Room Section ----
|
||||||
|
|
||||||
|
_renderRoomSection() {
|
||||||
|
const section = this._createSection('Rooms');
|
||||||
|
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-room-row' + (this._selectedRoomId === room.id ? ' active' : '');
|
||||||
|
|
||||||
|
const info = document.createElement('span');
|
||||||
|
info.className = 'he-room-info';
|
||||||
|
info.textContent = `${room.name} (${room.dimensions.width}\u00d7${room.dimensions.length}m)`;
|
||||||
|
info.addEventListener('click', () => {
|
||||||
|
this._selectedRoomId = room.id;
|
||||||
|
this.renderer.focusRoom(room.id);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
row.appendChild(info);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'he-icon-btn he-icon-btn-danger';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.title = 'Remove room';
|
||||||
|
removeBtn.addEventListener('click', () => this._removeRoom(room.id));
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'he-add-btn';
|
||||||
|
addBtn.textContent = '+ Add Room';
|
||||||
|
addBtn.addEventListener('click', () => this._showAddRoomForm(section, addBtn));
|
||||||
|
section.appendChild(addBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Room Editor ----
|
||||||
|
|
||||||
|
_renderRoomEditor() {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
const room = floor.rooms.find(r => r.id === this._selectedRoomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const section = this._createSection(`Edit: ${room.name}`);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameRow = this._createFieldRow('Name');
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.value = room.name;
|
||||||
|
nameInput.addEventListener('change', () => {
|
||||||
|
room.name = nameInput.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
nameRow.appendChild(nameInput);
|
||||||
|
section.appendChild(nameRow);
|
||||||
|
|
||||||
|
// English name
|
||||||
|
const nameEnRow = this._createFieldRow('Name (EN)');
|
||||||
|
const nameEnInput = document.createElement('input');
|
||||||
|
nameEnInput.className = 'he-input';
|
||||||
|
nameEnInput.value = room.nameEN || '';
|
||||||
|
nameEnInput.addEventListener('change', () => {
|
||||||
|
room.nameEN = nameEnInput.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
nameEnRow.appendChild(nameEnInput);
|
||||||
|
section.appendChild(nameEnRow);
|
||||||
|
|
||||||
|
// Type
|
||||||
|
const typeRow = this._createFieldRow('Type');
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.className = 'he-input';
|
||||||
|
const roomTypes = [
|
||||||
|
'living', 'kitchen', 'dining', 'bedroom', 'bathroom',
|
||||||
|
'hallway', 'office', 'utility', 'storage', 'garage'
|
||||||
|
];
|
||||||
|
for (const t of roomTypes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
if (room.type === t) opt.selected = true;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
typeSelect.addEventListener('change', () => {
|
||||||
|
room.type = typeSelect.value;
|
||||||
|
});
|
||||||
|
typeRow.appendChild(typeSelect);
|
||||||
|
section.appendChild(typeRow);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const dimsLabel = document.createElement('div');
|
||||||
|
dimsLabel.className = 'he-field-label';
|
||||||
|
dimsLabel.textContent = 'Dimensions';
|
||||||
|
section.appendChild(dimsLabel);
|
||||||
|
|
||||||
|
const dimsRow = document.createElement('div');
|
||||||
|
dimsRow.className = 'he-dims-row';
|
||||||
|
|
||||||
|
const wLabel = document.createElement('label');
|
||||||
|
wLabel.className = 'he-dim-label';
|
||||||
|
wLabel.textContent = 'W';
|
||||||
|
dimsRow.appendChild(wLabel);
|
||||||
|
const wInput = this._createNumberInput(room.dimensions.width, 1, 15, 0.25, (val) => {
|
||||||
|
room.dimensions.width = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
dimsRow.appendChild(wInput);
|
||||||
|
|
||||||
|
const lLabel = document.createElement('label');
|
||||||
|
lLabel.className = 'he-dim-label';
|
||||||
|
lLabel.textContent = 'L';
|
||||||
|
dimsRow.appendChild(lLabel);
|
||||||
|
const lInput = this._createNumberInput(room.dimensions.length, 1, 15, 0.25, (val) => {
|
||||||
|
room.dimensions.length = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
dimsRow.appendChild(lInput);
|
||||||
|
|
||||||
|
section.appendChild(dimsRow);
|
||||||
|
|
||||||
|
// Position
|
||||||
|
const posLabel = document.createElement('div');
|
||||||
|
posLabel.className = 'he-field-label';
|
||||||
|
posLabel.textContent = 'Position';
|
||||||
|
section.appendChild(posLabel);
|
||||||
|
|
||||||
|
const posRow = document.createElement('div');
|
||||||
|
posRow.className = 'he-dims-row';
|
||||||
|
|
||||||
|
const xLabel = document.createElement('label');
|
||||||
|
xLabel.className = 'he-dim-label';
|
||||||
|
xLabel.textContent = 'X';
|
||||||
|
posRow.appendChild(xLabel);
|
||||||
|
const xInput = this._createNumberInput(room.position.x, 0, 30, 0.25, (val) => {
|
||||||
|
room.position.x = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
posRow.appendChild(xInput);
|
||||||
|
|
||||||
|
const yLabel = document.createElement('label');
|
||||||
|
yLabel.className = 'he-dim-label';
|
||||||
|
yLabel.textContent = 'Y';
|
||||||
|
posRow.appendChild(yLabel);
|
||||||
|
const yInput = this._createNumberInput(room.position.y, 0, 30, 0.25, (val) => {
|
||||||
|
room.position.y = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
posRow.appendChild(yInput);
|
||||||
|
|
||||||
|
section.appendChild(posRow);
|
||||||
|
|
||||||
|
// Flooring
|
||||||
|
const flooringRow = this._createFieldRow('Flooring');
|
||||||
|
const flooringSelect = document.createElement('select');
|
||||||
|
flooringSelect.className = 'he-input';
|
||||||
|
for (const f of ['hardwood', 'tile']) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = f;
|
||||||
|
opt.textContent = f.charAt(0).toUpperCase() + f.slice(1);
|
||||||
|
if (room.flooring === f) opt.selected = true;
|
||||||
|
flooringSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
flooringSelect.addEventListener('change', () => {
|
||||||
|
room.flooring = flooringSelect.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
flooringRow.appendChild(flooringSelect);
|
||||||
|
section.appendChild(flooringRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save Section ----
|
||||||
|
|
||||||
|
_renderSaveSection() {
|
||||||
|
const section = this._createSection('Template');
|
||||||
|
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.className = 'he-save-btn';
|
||||||
|
saveBtn.textContent = 'Save as House Template';
|
||||||
|
saveBtn.addEventListener('click', () => this._saveAsTemplate());
|
||||||
|
section.appendChild(saveBtn);
|
||||||
|
|
||||||
|
const newBtn = document.createElement('button');
|
||||||
|
newBtn.className = 'he-add-btn';
|
||||||
|
newBtn.style.marginTop = '6px';
|
||||||
|
newBtn.textContent = 'New Empty House';
|
||||||
|
newBtn.addEventListener('click', () => this._createNewHouse());
|
||||||
|
section.appendChild(newBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
|
||||||
|
_addFloor() {
|
||||||
|
const floors = this.houseData.floors;
|
||||||
|
const level = floors.length;
|
||||||
|
const id = `floor-${level}`;
|
||||||
|
floors.push({
|
||||||
|
id,
|
||||||
|
name: `Floor ${level}`,
|
||||||
|
nameEN: `Floor ${level}`,
|
||||||
|
level,
|
||||||
|
ceilingHeight: 2.5,
|
||||||
|
rooms: []
|
||||||
|
});
|
||||||
|
this.onHouseChanged('floors');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeFloor(index) {
|
||||||
|
this.houseData.floors.splice(index, 1);
|
||||||
|
// Re-index levels
|
||||||
|
this.houseData.floors.forEach((f, i) => { f.level = i; });
|
||||||
|
// If current floor was removed, switch to last available
|
||||||
|
if (this.renderer.currentFloor >= this.houseData.floors.length) {
|
||||||
|
this.renderer.showFloor(this.houseData.floors.length - 1);
|
||||||
|
} else {
|
||||||
|
this._rebuildFloor();
|
||||||
|
}
|
||||||
|
this.onHouseChanged('floors');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showAddRoomForm(section, addBtn) {
|
||||||
|
// Replace button with form
|
||||||
|
addBtn.style.display = 'none';
|
||||||
|
|
||||||
|
const form = document.createElement('div');
|
||||||
|
form.className = 'he-add-room-form';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.placeholder = 'Room name';
|
||||||
|
form.appendChild(nameInput);
|
||||||
|
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.className = 'he-input';
|
||||||
|
const roomTypes = [
|
||||||
|
'living', 'kitchen', 'dining', 'bedroom', 'bathroom',
|
||||||
|
'hallway', 'office', 'utility', 'storage', 'garage'
|
||||||
|
];
|
||||||
|
for (const t of roomTypes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
form.appendChild(typeSelect);
|
||||||
|
|
||||||
|
const dimsRow = document.createElement('div');
|
||||||
|
dimsRow.className = 'he-dims-row';
|
||||||
|
const wInput = document.createElement('input');
|
||||||
|
wInput.className = 'he-input';
|
||||||
|
wInput.type = 'number';
|
||||||
|
wInput.value = '4';
|
||||||
|
wInput.min = '1';
|
||||||
|
wInput.max = '15';
|
||||||
|
wInput.step = '0.25';
|
||||||
|
wInput.placeholder = 'Width';
|
||||||
|
wInput.style.flex = '1';
|
||||||
|
dimsRow.appendChild(wInput);
|
||||||
|
|
||||||
|
const xSpan = document.createElement('span');
|
||||||
|
xSpan.textContent = '\u00d7';
|
||||||
|
xSpan.style.padding = '0 4px';
|
||||||
|
xSpan.style.color = '#999';
|
||||||
|
dimsRow.appendChild(xSpan);
|
||||||
|
|
||||||
|
const lInput = document.createElement('input');
|
||||||
|
lInput.className = 'he-input';
|
||||||
|
lInput.type = 'number';
|
||||||
|
lInput.value = '3';
|
||||||
|
lInput.min = '1';
|
||||||
|
lInput.max = '15';
|
||||||
|
lInput.step = '0.25';
|
||||||
|
lInput.placeholder = 'Length';
|
||||||
|
lInput.style.flex = '1';
|
||||||
|
dimsRow.appendChild(lInput);
|
||||||
|
form.appendChild(dimsRow);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'he-form-actions';
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'he-cancel-btn';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
form.remove();
|
||||||
|
addBtn.style.display = '';
|
||||||
|
});
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.className = 'he-submit-btn';
|
||||||
|
submitBtn.textContent = 'Add';
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { nameInput.focus(); return; }
|
||||||
|
this._addRoom({
|
||||||
|
name,
|
||||||
|
type: typeSelect.value,
|
||||||
|
width: parseFloat(wInput.value) || 4,
|
||||||
|
length: parseFloat(lInput.value) || 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
section.appendChild(form);
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addRoom({ name, type, width, length }) {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
// Auto-position: find rightmost edge of existing rooms
|
||||||
|
let maxX = 0;
|
||||||
|
for (const r of floor.rooms) {
|
||||||
|
const edge = r.position.x + r.dimensions.width;
|
||||||
|
if (edge > maxX) maxX = edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${floor.id}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36).slice(-4)}`;
|
||||||
|
const room = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
nameEN: name,
|
||||||
|
type,
|
||||||
|
position: { x: maxX + 0.24, y: 0 },
|
||||||
|
dimensions: { width, length },
|
||||||
|
flooring: (type === 'bathroom' || type === 'kitchen' || type === 'utility') ? 'tile' : 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior' },
|
||||||
|
north: { type: 'exterior' },
|
||||||
|
west: { type: 'interior' },
|
||||||
|
east: { type: 'exterior' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
floor.rooms.push(room);
|
||||||
|
this._rebuildFloor();
|
||||||
|
this._selectedRoomId = room.id;
|
||||||
|
this.onHouseChanged('rooms');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeRoom(roomId) {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
const idx = floor.rooms.findIndex(r => r.id === roomId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
floor.rooms.splice(idx, 1);
|
||||||
|
|
||||||
|
if (this._selectedRoomId === roomId) {
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._rebuildFloor();
|
||||||
|
this.onHouseChanged('rooms');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveAsTemplate() {
|
||||||
|
const data = structuredClone(this.houseData);
|
||||||
|
data.savedAt = new Date().toISOString();
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${(data.name || 'house').replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createNewHouse() {
|
||||||
|
const newHouse = {
|
||||||
|
name: 'New House',
|
||||||
|
description: '',
|
||||||
|
units: 'meters',
|
||||||
|
building: {
|
||||||
|
footprint: { width: 10, depth: 8 },
|
||||||
|
wallThickness: 0.24,
|
||||||
|
roofType: 'gable'
|
||||||
|
},
|
||||||
|
floors: [
|
||||||
|
{
|
||||||
|
id: 'floor-0',
|
||||||
|
name: 'Ground Floor',
|
||||||
|
nameEN: 'Ground Floor',
|
||||||
|
level: 0,
|
||||||
|
ceilingHeight: 2.6,
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: 'floor-0-hallway',
|
||||||
|
name: 'Hallway',
|
||||||
|
nameEN: 'Hallway',
|
||||||
|
type: 'hallway',
|
||||||
|
position: { x: 3.5, y: 0 },
|
||||||
|
dimensions: { width: 2.0, length: 8.0 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', doors: [{ id: 'entry', type: 'entry', position: 0.3, width: 1.1, height: 2.2, connectsTo: 'exterior' }] },
|
||||||
|
north: { type: 'exterior' },
|
||||||
|
west: { type: 'interior' },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floor-0-living',
|
||||||
|
name: 'Living Room',
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
type: 'living',
|
||||||
|
position: { x: 0, y: 3.0 },
|
||||||
|
dimensions: { width: 3.5, length: 5.0 },
|
||||||
|
flooring: 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'interior' },
|
||||||
|
north: { type: 'exterior', windows: [{ id: 'lr-w1', type: 'casement', position: 0.5, width: 1.4, height: 1.4, sillHeight: 0.6 }] },
|
||||||
|
west: { type: 'exterior', windows: [{ id: 'lr-w2', type: 'casement', position: 1.5, width: 1.2, height: 1.4, sillHeight: 0.6 }] },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floor-0-kitchen',
|
||||||
|
name: 'Kitchen',
|
||||||
|
nameEN: 'Kitchen',
|
||||||
|
type: 'kitchen',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 3.5, length: 3.0 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', windows: [{ id: 'k-w1', type: 'casement', position: 1.0, width: 1.2, height: 1.2, sillHeight: 0.9 }] },
|
||||||
|
north: { type: 'interior' },
|
||||||
|
west: { type: 'exterior' },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderer.houseData = newHouse;
|
||||||
|
this.renderer.showFloor(0);
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
this.onHouseChanged('new');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuildFloor() {
|
||||||
|
this.renderer.showFloor(this.renderer.currentFloor);
|
||||||
|
this.onHouseChanged('rebuild');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI Helpers ----
|
||||||
|
|
||||||
|
_createSection(title) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'he-section';
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'he-section-title';
|
||||||
|
h.textContent = title;
|
||||||
|
section.appendChild(h);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createFieldRow(label) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-field-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.className = 'he-field-label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
row.appendChild(lbl);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createNumberInput(value, min, max, step, onChange) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'he-input he-num-input';
|
||||||
|
input.type = 'number';
|
||||||
|
input.value = value;
|
||||||
|
input.min = min;
|
||||||
|
input.max = max;
|
||||||
|
input.step = step;
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
if (!isNaN(val) && val >= min && val <= max) {
|
||||||
|
onChange(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
662
src/index.html
662
src/index.html
@@ -14,7 +14,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 280px;
|
width: 300px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border-left: 1px solid #ddd;
|
border-left: 1px solid #ddd;
|
||||||
@@ -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,514 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source tabs */
|
||||||
|
.catalog-source-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.catalog-source-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.catalog-source-btn.active {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.catalog-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Series filter */
|
||||||
|
.catalog-series {
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.catalog-series-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.catalog-series-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.catalog-series-btn.active {
|
||||||
|
background: #0058a3;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0058a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IKEA badge */
|
||||||
|
.catalog-item-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #0058a3;
|
||||||
|
color: #ffda1a;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.catalog-item-series {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #0058a3;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom furniture creator */
|
||||||
|
.catalog-create-btn {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
margin: 8px 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px dashed #4a90d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.catalog-create-btn:hover {
|
||||||
|
background: #e8f0fe;
|
||||||
|
}
|
||||||
|
.catalog-create-form {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.catalog-create-form label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.catalog-create-form input,
|
||||||
|
.catalog-create-form select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.catalog-create-form input:focus,
|
||||||
|
.catalog-create-form select:focus {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.catalog-create-dims {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.catalog-create-dims > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.catalog-create-dims label {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
.catalog-create-color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.catalog-create-color-row input[type="color"] {
|
||||||
|
width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 1px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.catalog-create-color-row input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.catalog-create-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.catalog-create-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.catalog-create-actions .btn-submit {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.catalog-create-actions .btn-submit:hover {
|
||||||
|
background: #3a7bc8;
|
||||||
|
}
|
||||||
|
.catalog-create-actions .btn-cancel {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.catalog-create-actions .btn-cancel:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* House Editor */
|
||||||
|
#house-editor {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.he-toggle-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.he-toggle-btn:hover { background: #e8f0fe; }
|
||||||
|
.he-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.he-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.he-field-row {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.he-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.he-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.he-input:focus { border-color: #4a90d9; }
|
||||||
|
.he-num-input { width: 65px; }
|
||||||
|
.he-dims-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.he-dim-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
.he-dims-row .he-input { flex: 1; min-width: 0; }
|
||||||
|
.he-floor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.he-floor-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.he-room-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.he-room-row:hover { background: #f0f4fa; }
|
||||||
|
.he-room-row.active { background: #e0eaf5; }
|
||||||
|
.he-room-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.he-icon-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.he-icon-btn-danger { color: #c44; }
|
||||||
|
.he-icon-btn-danger:hover { background: #fdd; }
|
||||||
|
.he-add-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px dashed #aaa;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.he-add-btn:hover { background: #f5f5f5; border-color: #4a90d9; color: #4a90d9; }
|
||||||
|
.he-add-room-form {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.he-add-room-form .he-input { margin-bottom: 5px; }
|
||||||
|
.he-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.he-cancel-btn, .he-submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.he-cancel-btn { background: #fff; }
|
||||||
|
.he-cancel-btn:hover { background: #f0f0f0; }
|
||||||
|
.he-submit-btn { background: #4a90d9; color: #fff; border-color: #4a90d9; }
|
||||||
|
.he-submit-btn:hover { background: #3a7bc8; }
|
||||||
|
.he-save-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.he-save-btn:hover { background: #3a7bc8; }
|
||||||
</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>
|
||||||
|
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
|
||||||
|
</div>
|
||||||
|
<div id="house-editor"></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 +604,98 @@
|
|||||||
</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';
|
||||||
|
import { HouseEditor } from './house-editor.js';
|
||||||
|
import { FloorplanImporter } from './floorplan-import.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;
|
||||||
|
let houseEditor = null;
|
||||||
|
let floorplanImporter = 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');
|
// Merge IKEA catalog items into the main catalog
|
||||||
|
await houseRenderer.mergeCatalog('../data/ikea-catalog.json').catch(e =>
|
||||||
|
console.warn('IKEA catalog not loaded:', e.message)
|
||||||
|
);
|
||||||
|
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
|
||||||
|
});
|
||||||
|
houseEditor = new HouseEditor(document.getElementById('house-editor'), {
|
||||||
|
renderer: houseRenderer,
|
||||||
|
onHouseChanged: (what) => {
|
||||||
|
document.getElementById('house-name').textContent = houseRenderer.houseData.name;
|
||||||
buildFloorButtons();
|
buildFloorButtons();
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
floorplanImporter = new FloorplanImporter(houseRenderer, {
|
||||||
|
onHouseLoaded: (houseData) => {
|
||||||
|
document.getElementById('house-name').textContent = houseData.name;
|
||||||
|
buildFloorButtons();
|
||||||
|
buildRoomList();
|
||||||
|
selectedRoom = null;
|
||||||
|
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||||
|
if (houseEditor) houseEditor.setSelectedRoom(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
buildFloorButtons();
|
||||||
|
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);
|
||||||
|
if (houseEditor) houseEditor.setSelectedRoom(null);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -123,7 +704,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 +716,13 @@
|
|||||||
|
|
||||||
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);
|
||||||
|
if (houseEditor) houseEditor.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 +731,59 @@
|
|||||||
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();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-import-floorplan').addEventListener('click', () => {
|
||||||
|
if (floorplanImporter) floorplanImporter.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update UI when a floor plan is imported
|
||||||
|
viewer.addEventListener('houseloaded', (e) => {
|
||||||
|
document.getElementById('info').textContent =
|
||||||
|
`Imported floor plan: ${e.detail.name} (${e.detail.floors} floor${e.detail.floors > 1 ? 's' : ''})`;
|
||||||
|
});
|
||||||
</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));
|
||||||
|
}
|
||||||
260
src/renderer.js
260
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,87 @@ 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 mergeCatalog(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
|
||||||
|
const extra = await res.json();
|
||||||
|
if (!this.catalogData) {
|
||||||
|
return this.loadCatalog(url);
|
||||||
|
}
|
||||||
|
// Merge categories
|
||||||
|
for (const cat of extra.categories || []) {
|
||||||
|
if (!this.catalogData.categories.includes(cat)) {
|
||||||
|
this.catalogData.categories.push(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge items, avoiding duplicates by id
|
||||||
|
for (const item of extra.items || []) {
|
||||||
|
if (!this._catalogIndex.has(item.id)) {
|
||||||
|
this.catalogData.items.push(item);
|
||||||
|
this._catalogIndex.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store extra catalog for tabbed access
|
||||||
|
if (!this._extraCatalogs) this._extraCatalogs = [];
|
||||||
|
this._extraCatalogs.push(extra);
|
||||||
|
return extra;
|
||||||
|
} catch (err) {
|
||||||
|
this._emitError('mergeCatalog', 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 +182,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 +253,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 +322,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 +346,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 +420,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 +439,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 +461,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 +470,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 +545,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 +595,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 +606,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 +625,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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tests/__mocks__/OrbitControls.js
Normal file
10
tests/__mocks__/OrbitControls.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export class OrbitControls {
|
||||||
|
constructor() {
|
||||||
|
this.target = { set: () => {} };
|
||||||
|
this.enableDamping = false;
|
||||||
|
this.dampingFactor = 0;
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
update() {}
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
199
tests/__mocks__/three.js
Normal file
199
tests/__mocks__/three.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Minimal THREE.js mock for unit testing
|
||||||
|
|
||||||
|
export class Vector2 {
|
||||||
|
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
|
||||||
|
set(x, y) { this.x = x; this.y = y; return this; }
|
||||||
|
copy(v) { this.x = v.x; this.y = v.y; return this; }
|
||||||
|
clone() { return new Vector2(this.x, this.y); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vector3 {
|
||||||
|
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||||
|
set(x, y, z) { this.x = x; this.y = y; this.z = z; return this; }
|
||||||
|
copy(v) { this.x = v.x; this.y = v.y; this.z = v.z; return this; }
|
||||||
|
clone() { return new Vector3(this.x, this.y, this.z); }
|
||||||
|
sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; }
|
||||||
|
add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; }
|
||||||
|
multiplyScalar(s) { this.x *= s; this.y *= s; this.z *= s; return this; }
|
||||||
|
equals(v) { return this.x === v.x && this.y === v.y && this.z === v.z; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Euler {
|
||||||
|
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||||
|
copy(e) { this.x = e.x; this.y = e.y; this.z = e.z; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Color {
|
||||||
|
constructor(c) { this._hex = typeof c === 'number' ? c : 0; }
|
||||||
|
setHex(h) { this._hex = h; return this; }
|
||||||
|
clone() { return new Color(this._hex); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Object3D {
|
||||||
|
constructor() {
|
||||||
|
this.children = [];
|
||||||
|
this.parent = null;
|
||||||
|
this.position = new Vector3();
|
||||||
|
this.rotation = new Euler();
|
||||||
|
this.scale = new Vector3(1, 1, 1);
|
||||||
|
this.userData = {};
|
||||||
|
}
|
||||||
|
add(child) { this.children.push(child); child.parent = this; }
|
||||||
|
remove(child) {
|
||||||
|
const i = this.children.indexOf(child);
|
||||||
|
if (i >= 0) this.children.splice(i, 1);
|
||||||
|
child.parent = null;
|
||||||
|
}
|
||||||
|
traverse(fn) {
|
||||||
|
fn(this);
|
||||||
|
for (const child of this.children) child.traverse(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Group extends Object3D {}
|
||||||
|
|
||||||
|
export class Scene extends Object3D {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.background = new Color(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Mesh extends Object3D {
|
||||||
|
constructor(geometry, material) {
|
||||||
|
super();
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
this.isMesh = true;
|
||||||
|
this.castShadow = false;
|
||||||
|
this.receiveShadow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sprite extends Object3D {
|
||||||
|
constructor(material) {
|
||||||
|
super();
|
||||||
|
this.material = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LineSegments extends Object3D {
|
||||||
|
constructor(geometry, material) {
|
||||||
|
super();
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoxGeometry {
|
||||||
|
constructor(w, h, d) { this.parameters = { width: w, height: h, depth: d }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaneGeometry {
|
||||||
|
constructor(w, h) { this.parameters = { width: w, height: h }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CylinderGeometry {
|
||||||
|
constructor(rT, rB, h, s) { this.parameters = { radiusTop: rT, radiusBottom: rB, height: h, radialSegments: s }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EdgesGeometry {
|
||||||
|
constructor(geo) { this._source = geo; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshStandardMaterial {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
Object.assign(this, opts);
|
||||||
|
this.emissive = new Color(0);
|
||||||
|
}
|
||||||
|
clone() { const m = new MeshStandardMaterial(); Object.assign(m, this); m.emissive = this.emissive.clone(); return m; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshBasicMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpriteMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LineBasicMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanvasTexture {
|
||||||
|
constructor() {}
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plane {
|
||||||
|
constructor(normal, constant) { this.normal = normal; this.constant = constant; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Raycaster {
|
||||||
|
constructor() { this.ray = { intersectPlane: () => new Vector3() }; }
|
||||||
|
setFromCamera() {}
|
||||||
|
intersectObject() { return []; }
|
||||||
|
intersectObjects() { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerspectiveCamera extends Object3D {
|
||||||
|
constructor(fov, aspect, near, far) {
|
||||||
|
super();
|
||||||
|
this.fov = fov;
|
||||||
|
this.aspect = aspect;
|
||||||
|
this.near = near;
|
||||||
|
this.far = far;
|
||||||
|
}
|
||||||
|
lookAt() {}
|
||||||
|
updateProjectionMatrix() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AmbientLight extends Object3D {
|
||||||
|
constructor(color, intensity) {
|
||||||
|
super();
|
||||||
|
this.color = new Color(color);
|
||||||
|
this.intensity = intensity;
|
||||||
|
this.isAmbientLight = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DirectionalLight extends Object3D {
|
||||||
|
constructor(color, intensity) {
|
||||||
|
super();
|
||||||
|
this.color = new Color(color);
|
||||||
|
this.intensity = intensity;
|
||||||
|
this.isDirectionalLight = true;
|
||||||
|
this.castShadow = false;
|
||||||
|
this.shadow = {
|
||||||
|
mapSize: { width: 0, height: 0 },
|
||||||
|
camera: { left: 0, right: 0, top: 0, bottom: 0, near: 0, far: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GridHelper extends Object3D {
|
||||||
|
constructor() { super(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebGLRenderer {
|
||||||
|
constructor() {
|
||||||
|
this.domElement = { addEventListener: () => {}, removeEventListener: () => {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), toDataURL: () => 'data:image/png;base64,fake' };
|
||||||
|
this.shadowMap = { enabled: false };
|
||||||
|
}
|
||||||
|
setSize() {}
|
||||||
|
setPixelRatio() {}
|
||||||
|
getPixelRatio() { return 1; }
|
||||||
|
getSize(v) { v.set(800, 600); }
|
||||||
|
render() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoubleSide = 2;
|
||||||
372
tests/catalog.test.js
Normal file
372
tests/catalog.test.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CatalogPanel } from '../src/catalog.js';
|
||||||
|
|
||||||
|
function makeCatalogData() {
|
||||||
|
return {
|
||||||
|
categories: ['seating', 'tables', 'storage'],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'chair-1', name: 'Dining Chair', category: 'seating',
|
||||||
|
dimensions: { width: 0.45, depth: 0.5, height: 0.9 },
|
||||||
|
rooms: ['wohnzimmer'],
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#8b4513' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'table-1', name: 'Kitchen Table', category: 'tables',
|
||||||
|
dimensions: { width: 1.4, depth: 0.8, height: 0.75 },
|
||||||
|
rooms: ['küche'],
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#d2b48c' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ikea-shelf-1', name: 'KALLAX Shelf', category: 'storage',
|
||||||
|
dimensions: { width: 0.77, depth: 0.39, height: 1.47 },
|
||||||
|
rooms: [],
|
||||||
|
ikeaSeries: 'KALLAX',
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#ffffff' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ikea-chair-1', name: 'POÄNG Chair', category: 'seating',
|
||||||
|
dimensions: { width: 0.68, depth: 0.82, height: 1.0 },
|
||||||
|
rooms: ['wohnzimmer'],
|
||||||
|
ikeaSeries: 'POÄNG',
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#b5651d' }] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer(catalogData) {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
catalogData: catalogData || makeCatalogData(),
|
||||||
|
_catalogIndex: new Map((catalogData || makeCatalogData()).items.map(i => [i.id, i])),
|
||||||
|
container: {
|
||||||
|
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
_listeners: listeners
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
id: 'eg-wohnzimmer', name: 'Wohnzimmer', type: 'living',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 4 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
getRooms: () => [
|
||||||
|
{ id: 'eg-wohnzimmer', name: 'Wohnzimmer', nameEN: 'Living Room', type: 'living', area: 20 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockState() {
|
||||||
|
return {
|
||||||
|
addFurniture: vi.fn(() => 0),
|
||||||
|
onChange: vi.fn(() => () => {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockInteraction() {
|
||||||
|
return {
|
||||||
|
selectedRoomId: null,
|
||||||
|
onChange: vi.fn(() => () => {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContainer() {
|
||||||
|
// Minimal DOM element mock
|
||||||
|
const el = {
|
||||||
|
innerHTML: '',
|
||||||
|
className: '',
|
||||||
|
style: {},
|
||||||
|
children: [],
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: (sel) => {
|
||||||
|
if (sel === '.catalog-count') return { textContent: '' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dataset: {}
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a DOM element factory for jsdom-free testing
|
||||||
|
function setupDomMock() {
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
globalThis.document = {
|
||||||
|
createElement: (tag) => {
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
className: '',
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
value: '',
|
||||||
|
type: '',
|
||||||
|
placeholder: '',
|
||||||
|
step: '',
|
||||||
|
min: '',
|
||||||
|
max: '',
|
||||||
|
children: [],
|
||||||
|
_listeners: {},
|
||||||
|
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
|
||||||
|
addEventListener: vi.fn(function(type, fn) {
|
||||||
|
this._listeners[type] = this._listeners[type] || [];
|
||||||
|
this._listeners[type].push(fn);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
querySelector: function(sel) {
|
||||||
|
if (sel === '.catalog-count') return { textContent: '' };
|
||||||
|
if (sel === '.catalog-item-add') return { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
focus: vi.fn(),
|
||||||
|
click: vi.fn()
|
||||||
|
};
|
||||||
|
elements.push(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CatalogPanel', () => {
|
||||||
|
let panel, container, mockRenderer, mockState, mockInteraction;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupDomMock();
|
||||||
|
container = makeContainer();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
mockState = makeMockState();
|
||||||
|
mockInteraction = makeMockInteraction();
|
||||||
|
panel = new CatalogPanel(container, {
|
||||||
|
renderer: mockRenderer,
|
||||||
|
state: mockState,
|
||||||
|
interaction: mockInteraction
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('sets default filter state', () => {
|
||||||
|
expect(panel.selectedSource).toBe('all');
|
||||||
|
expect(panel.selectedCategory).toBe('all');
|
||||||
|
expect(panel.selectedSeries).toBe('all');
|
||||||
|
expect(panel.searchQuery).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores renderer, state, and interaction references', () => {
|
||||||
|
expect(panel.renderer).toBe(mockRenderer);
|
||||||
|
expect(panel.state).toBe(mockState);
|
||||||
|
expect(panel.interaction).toBe(mockInteraction);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_hasIkeaItems', () => {
|
||||||
|
it('returns true when IKEA items exist', () => {
|
||||||
|
expect(panel._hasIkeaItems()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false with no IKEA items', () => {
|
||||||
|
mockRenderer.catalogData.items = mockRenderer.catalogData.items.filter(i => !i.id.startsWith('ikea-'));
|
||||||
|
expect(panel._hasIkeaItems()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
expect(panel._hasIkeaItems()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getSourceFilteredItems', () => {
|
||||||
|
it('returns all items for "all" source', () => {
|
||||||
|
panel.selectedSource = 'all';
|
||||||
|
expect(panel._getSourceFilteredItems()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only IKEA items for "ikea" source', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
const items = panel._getSourceFilteredItems();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items.every(i => i.id.startsWith('ikea-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only standard items for "standard" source', () => {
|
||||||
|
panel.selectedSource = 'standard';
|
||||||
|
const items = panel._getSourceFilteredItems();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items.every(i => !i.id.startsWith('ikea-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
expect(panel._getSourceFilteredItems()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getFilteredItems', () => {
|
||||||
|
it('filters by category', () => {
|
||||||
|
panel.selectedCategory = 'seating';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(2); // chair-1 + ikea-chair-1
|
||||||
|
expect(items.every(i => i.category === 'seating')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by source + category', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
panel.selectedCategory = 'seating';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by series', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
panel.selectedSeries = 'KALLAX';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-shelf-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by search query', () => {
|
||||||
|
panel.searchQuery = 'kallax';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-shelf-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search matches on name, id, and category', () => {
|
||||||
|
panel.searchQuery = 'chair';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(2); // chair-1, ikea-chair-1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search is case-insensitive', () => {
|
||||||
|
panel.searchQuery = 'DINING';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combined filters narrow results', () => {
|
||||||
|
panel.selectedSource = 'standard';
|
||||||
|
panel.selectedCategory = 'tables';
|
||||||
|
panel.searchQuery = 'kitchen';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('table-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no matches returns empty array', () => {
|
||||||
|
panel.searchQuery = 'nonexistent-item-xyz';
|
||||||
|
expect(panel._getFilteredItems()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getTargetRoom', () => {
|
||||||
|
it('uses selectedRoomId if set', () => {
|
||||||
|
panel.selectedRoomId = 'custom-room';
|
||||||
|
expect(panel._getTargetRoom({})).toBe('custom-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to interaction selectedRoomId', () => {
|
||||||
|
mockInteraction.selectedRoomId = 'interaction-room';
|
||||||
|
expect(panel._getTargetRoom({})).toBe('interaction-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches catalog item room hints to floor rooms', () => {
|
||||||
|
const item = { rooms: ['wohnzimmer'] };
|
||||||
|
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first room when no hint matches', () => {
|
||||||
|
const item = { rooms: ['bathroom'] };
|
||||||
|
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first room with no hints', () => {
|
||||||
|
expect(panel._getTargetRoom({ rooms: [] })).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null with no rooms available', () => {
|
||||||
|
mockRenderer.getRooms = () => [];
|
||||||
|
expect(panel._getTargetRoom({ rooms: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_placeItem', () => {
|
||||||
|
it('calls state.addFurniture with correct placement', () => {
|
||||||
|
const item = { id: 'chair-1', rooms: ['wohnzimmer'] };
|
||||||
|
panel._placeItem(item);
|
||||||
|
|
||||||
|
expect(mockState.addFurniture).toHaveBeenCalledTimes(1);
|
||||||
|
const [roomId, placement] = mockState.addFurniture.mock.calls[0];
|
||||||
|
expect(roomId).toBe('eg-wohnzimmer');
|
||||||
|
expect(placement.catalogId).toBe('chair-1');
|
||||||
|
expect(placement.position.x).toBe(2.5); // center of 5m wide room
|
||||||
|
expect(placement.position.z).toBe(2); // center of 4m long room
|
||||||
|
expect(placement.rotation).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no target room found', () => {
|
||||||
|
mockRenderer.getRooms = () => [];
|
||||||
|
panel._placeItem({ id: 'x', rooms: [] });
|
||||||
|
expect(mockState.addFurniture).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSelectedRoom', () => {
|
||||||
|
it('sets selectedRoomId', () => {
|
||||||
|
panel.setSelectedRoom('room-x');
|
||||||
|
expect(panel.selectedRoomId).toBe('room-x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears with null', () => {
|
||||||
|
panel.setSelectedRoom('room-x');
|
||||||
|
panel.setSelectedRoom(null);
|
||||||
|
expect(panel.selectedRoomId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_addCustomItem', () => {
|
||||||
|
it('adds item to catalog', () => {
|
||||||
|
const countBefore = mockRenderer.catalogData.items.length;
|
||||||
|
panel._addCustomItem({
|
||||||
|
name: 'My Shelf', width: 1, depth: 0.5, height: 1.5,
|
||||||
|
color: '#aabbcc', category: 'storage'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRenderer.catalogData.items.length).toBe(countBefore + 1);
|
||||||
|
const added = mockRenderer.catalogData.items[countBefore];
|
||||||
|
expect(added.id).toBe('custom-my-shelf');
|
||||||
|
expect(added.name).toBe('My Shelf');
|
||||||
|
expect(added.category).toBe('storage');
|
||||||
|
expect(added.dimensions).toEqual({ width: 1, depth: 0.5, height: 1.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique id on collision', () => {
|
||||||
|
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
|
||||||
|
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
|
||||||
|
|
||||||
|
const customItems = mockRenderer.catalogData.items.filter(i => i.id.startsWith('custom-shelf'));
|
||||||
|
expect(customItems).toHaveLength(2);
|
||||||
|
expect(customItems[0].id).not.toBe(customItems[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
panel._addCustomItem({ name: 'X', width: 1, depth: 1, height: 1, color: '#000', category: 'storage' });
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
190
tests/export.test.js
Normal file
190
tests/export.test.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ExportManager } from '../src/export.js';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
import { Vector2 } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
return {
|
||||||
|
renderer: {
|
||||||
|
getSize: vi.fn((v) => v.set(800, 600)),
|
||||||
|
getPixelRatio: vi.fn(() => 1),
|
||||||
|
setSize: vi.fn(),
|
||||||
|
setPixelRatio: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
domElement: {
|
||||||
|
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
aspect: 800 / 600,
|
||||||
|
updateProjectionMatrix: vi.fn()
|
||||||
|
},
|
||||||
|
scene: {},
|
||||||
|
container: {
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{ id: 'eg', rooms: [{ id: 'r1', name: 'Room1' }] }]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
designData: null,
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
_placeFurnitureForFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDesign() {
|
||||||
|
return {
|
||||||
|
name: 'Test Design',
|
||||||
|
rooms: [{ roomId: 'r1', furniture: [{ catalogId: 'c1', position: { x: 1, z: 2 }, rotation: 0 }] }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExportManager', () => {
|
||||||
|
let exportMgr, state, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Stub DOM APIs
|
||||||
|
globalThis.URL = { createObjectURL: vi.fn(() => 'blob:url'), revokeObjectURL: vi.fn() };
|
||||||
|
globalThis.Blob = class Blob { constructor(parts, opts) { this.parts = parts; this.type = opts?.type; } };
|
||||||
|
globalThis.document = globalThis.document || {};
|
||||||
|
globalThis.document.createElement = (tag) => {
|
||||||
|
return { href: '', download: '', click: vi.fn(), type: '', accept: '', files: [], addEventListener: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
state = new DesignState(makeDesign());
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
exportMgr = new ExportManager(mockRenderer, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportDesignJSON', () => {
|
||||||
|
it('creates a download with correct filename', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
|
||||||
|
expect(mockAnchor.download).toBe('Test Design.json');
|
||||||
|
expect(mockAnchor.click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes exportedAt timestamp', () => {
|
||||||
|
let blobContent = '';
|
||||||
|
globalThis.Blob = class {
|
||||||
|
constructor(parts) { blobContent = parts[0]; }
|
||||||
|
};
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
const data = JSON.parse(blobContent);
|
||||||
|
expect(data.exportedAt).toBeDefined();
|
||||||
|
expect(new Date(data.exportedAt).getTime()).not.toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports current state data', () => {
|
||||||
|
let blobContent = '';
|
||||||
|
globalThis.Blob = class {
|
||||||
|
constructor(parts) { blobContent = parts[0]; }
|
||||||
|
};
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
const data = JSON.parse(blobContent);
|
||||||
|
expect(data.name).toBe('Test Design');
|
||||||
|
expect(data.rooms).toHaveLength(1);
|
||||||
|
expect(data.rooms[0].furniture[0].catalogId).toBe('c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "design" as fallback filename', () => {
|
||||||
|
// State with no name
|
||||||
|
state.loadDesign({ rooms: [] });
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
expect(mockAnchor.download).toBe('design.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_loadDesignFile', () => {
|
||||||
|
it('loads valid design file', async () => {
|
||||||
|
const design = { name: 'Loaded', rooms: [{ roomId: 'r2', furniture: [] }] };
|
||||||
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'loaded.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(state.design.name).toBe('Loaded');
|
||||||
|
expect(state.design.rooms).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects file without rooms array', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const file = { text: () => Promise.resolve('{"name": "bad"}'), name: 'bad.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
||||||
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
||||||
|
c => c[0].type === 'loaderror'
|
||||||
|
);
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const file = { text: () => Promise.resolve('not json'), name: 'bad.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches designloaded event on success', async () => {
|
||||||
|
const design = { name: 'Success', rooms: [{ roomId: 'r1', furniture: [] }] };
|
||||||
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'test.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
||||||
|
c => c[0].type === 'designloaded'
|
||||||
|
);
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
expect(event[0].detail.name).toBe('Success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportScreenshot', () => {
|
||||||
|
it('renders at requested resolution', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportScreenshot(1920, 1080);
|
||||||
|
|
||||||
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledWith(1920, 1080);
|
||||||
|
expect(mockRenderer.renderer.render).toHaveBeenCalled();
|
||||||
|
expect(mockAnchor.download).toBe('house-design.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores original renderer size after capture', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportScreenshot();
|
||||||
|
|
||||||
|
// setSize called twice: once for capture, once to restore
|
||||||
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledTimes(2);
|
||||||
|
// Last call restores original 800x600
|
||||||
|
const lastCall = mockRenderer.renderer.setSize.mock.calls[1];
|
||||||
|
expect(lastCall).toEqual([800, 600]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates camera projection matrix twice', () => {
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
exportMgr.exportScreenshot();
|
||||||
|
expect(mockRenderer.camera.updateProjectionMatrix).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
292
tests/floorplan-import.test.js
Normal file
292
tests/floorplan-import.test.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { FloorplanImporter } from '../src/floorplan-import.js';
|
||||||
|
|
||||||
|
// Minimal renderer mock
|
||||||
|
function makeRenderer() {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
houseData: null,
|
||||||
|
currentFloor: 0,
|
||||||
|
container: {
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
addEventListener: (type, fn) => {
|
||||||
|
listeners[type] = listeners[type] || [];
|
||||||
|
listeners[type].push(fn);
|
||||||
|
},
|
||||||
|
_listeners: listeners
|
||||||
|
},
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
showFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid house data for testing
|
||||||
|
function makeSampleHouse() {
|
||||||
|
return {
|
||||||
|
name: 'Test House',
|
||||||
|
description: 'A test house',
|
||||||
|
units: 'meters',
|
||||||
|
building: {
|
||||||
|
footprint: { width: 10, depth: 8 },
|
||||||
|
wallThickness: 0.24,
|
||||||
|
roofType: 'gable'
|
||||||
|
},
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
name: 'Ground Floor',
|
||||||
|
nameEN: 'Ground Floor',
|
||||||
|
level: 0,
|
||||||
|
ceilingHeight: 2.6,
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: 'eg-living',
|
||||||
|
name: 'Living Room',
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
type: 'living',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 4 },
|
||||||
|
flooring: 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
north: { type: 'interior', doors: [{ id: 'eg-living-d1', type: 'interior', position: 1, width: 0.9, height: 2.1, connectsTo: 'eg-kitchen' }], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'exterior', doors: [], windows: [{ id: 'eg-living-w1', type: 'casement', position: 1.5, width: 1.2, height: 1.2, sillHeight: 0.9 }] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eg-kitchen',
|
||||||
|
name: 'Kitchen',
|
||||||
|
nameEN: 'Kitchen',
|
||||||
|
type: 'kitchen',
|
||||||
|
position: { x: 0, y: 4 },
|
||||||
|
dimensions: { width: 5, length: 4 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'interior', doors: [], windows: [] },
|
||||||
|
north: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'exterior', doors: [], windows: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FloorplanImporter', () => {
|
||||||
|
let renderer;
|
||||||
|
let importer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderer = makeRenderer();
|
||||||
|
importer = new FloorplanImporter(renderer);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_validateHouseJSON', () => {
|
||||||
|
it('validates correct house data', () => {
|
||||||
|
const result = importer._validateHouseJSON(makeSampleHouse());
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing name', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.name;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing building name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing building footprint', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.building.footprint;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing building footprint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing floors', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
data.floors = [];
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('No floors found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches rooms with missing walls', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.floors[0].rooms[0].walls;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Room "eg-living" missing walls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches invalid wall type', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
data.floors[0].rooms[0].walls.south.type = 'concrete';
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some(e => e.includes('invalid type'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_autoRepair', () => {
|
||||||
|
it('adds missing name', () => {
|
||||||
|
const data = { floors: [{ rooms: [] }] };
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.name).toBe('Imported Floor Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds missing units', () => {
|
||||||
|
const data = { floors: [] };
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.units).toBe('meters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts string numbers to floats', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [{
|
||||||
|
id: 'test',
|
||||||
|
position: { x: '3.5', y: '2.0' },
|
||||||
|
dimensions: { width: '4', length: '5.5' },
|
||||||
|
walls: {
|
||||||
|
north: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
south: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'interior', doors: [], windows: [] }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
const room = repaired.floors[0].rooms[0];
|
||||||
|
expect(room.position.x).toBe(3.5);
|
||||||
|
expect(room.position.y).toBe(2.0);
|
||||||
|
expect(room.dimensions.width).toBe(4);
|
||||||
|
expect(room.dimensions.length).toBe(5.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates missing IDs', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 4, length: 4 },
|
||||||
|
walls: {}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.floors[0].rooms[0].id).toBe('eg-living-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers flooring from room type', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [
|
||||||
|
{ id: 'eg-k', type: 'kitchen', position: { x: 0, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} },
|
||||||
|
{ id: 'eg-b', type: 'bedroom', position: { x: 3, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.floors[0].rooms[0].flooring).toBe('tile');
|
||||||
|
expect(repaired.floors[0].rooms[1].flooring).toBe('hardwood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds missing walls', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [{
|
||||||
|
id: 'test',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 3, length: 3 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
const room = repaired.floors[0].rooms[0];
|
||||||
|
expect(room.walls.north).toBeDefined();
|
||||||
|
expect(room.walls.south).toBeDefined();
|
||||||
|
expect(room.walls.east).toBeDefined();
|
||||||
|
expect(room.walls.west).toBeDefined();
|
||||||
|
expect(room.walls.north.type).toBe('interior');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes footprint from rooms when missing', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [
|
||||||
|
{ id: 'r1', position: { x: 0, y: 0 }, dimensions: { width: 5, length: 4 }, walls: {} },
|
||||||
|
{ id: 'r2', position: { x: 5, y: 0 }, dimensions: { width: 3, length: 4 }, walls: {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.building.footprint.width).toBe(8);
|
||||||
|
expect(repaired.building.footprint.depth).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_applyToRenderer', () => {
|
||||||
|
it('sets house data on renderer', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer.houseData).toBe(data);
|
||||||
|
expect(renderer.currentFloor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears and renders floor', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer._clearFloor).toHaveBeenCalled();
|
||||||
|
expect(renderer._renderRoom).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches houseloaded event', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer.container.dispatchEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'houseloaded',
|
||||||
|
detail: { name: 'Test House', floors: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onHouseLoaded callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const importerWithCb = new FloorplanImporter(renderer, { onHouseLoaded: callback });
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importerWithCb._applyToRenderer(data);
|
||||||
|
expect(callback).toHaveBeenCalledWith(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildPrompt', () => {
|
||||||
|
it('includes building name', () => {
|
||||||
|
const prompt = importer._buildPrompt('My House', 1, '');
|
||||||
|
expect(prompt).toContain('My House');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes scale hint when provided', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 1, 'Living room is 5m wide');
|
||||||
|
expect(prompt).toContain('Scale reference: Living room is 5m wide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default scale message when no hint', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 1, '');
|
||||||
|
expect(prompt).toContain('Estimate dimensions from standard door widths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes floor count', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 2, '');
|
||||||
|
expect(prompt).toContain('2 floor(s)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
311
tests/interaction.test.js
Normal file
311
tests/interaction.test.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { InteractionManager } from '../src/interaction.js';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
import { Group, Vector3, Euler } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
function makeState() {
|
||||||
|
return new DesignState({
|
||||||
|
name: 'Test',
|
||||||
|
rooms: [{
|
||||||
|
roomId: 'room-1',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'chair', position: { x: 1, z: 2 }, rotation: 0 },
|
||||||
|
{ catalogId: 'table', position: { x: 3, z: 4 }, rotation: 90 }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
const listeners = {};
|
||||||
|
const canvasListeners = {};
|
||||||
|
const container = {
|
||||||
|
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const furnitureMeshes = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
renderer: {
|
||||||
|
domElement: {
|
||||||
|
addEventListener: (t, fn) => { canvasListeners[t] = canvasListeners[t] || []; canvasListeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
camera: { position: new Vector3(6, 12, 14) },
|
||||||
|
raycaster: {
|
||||||
|
setFromCamera: vi.fn(),
|
||||||
|
intersectObject: vi.fn(() => []),
|
||||||
|
ray: { intersectPlane: vi.fn(() => new Vector3()) }
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
id: 'room-1',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 5 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
furnitureMeshes,
|
||||||
|
setControlsEnabled: vi.fn(),
|
||||||
|
designData: {},
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
_placeFurnitureForFloor: vi.fn(),
|
||||||
|
_listeners: listeners,
|
||||||
|
_canvasListeners: canvasListeners
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InteractionManager', () => {
|
||||||
|
let interaction, state, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.window = globalThis.window || {};
|
||||||
|
globalThis.window.addEventListener = vi.fn();
|
||||||
|
globalThis.window.removeEventListener = vi.fn();
|
||||||
|
|
||||||
|
state = makeState();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
interaction = new InteractionManager(mockRenderer, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode system', () => {
|
||||||
|
it('starts in view mode', () => {
|
||||||
|
expect(interaction.mode).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode changes mode and emits event', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.setMode('select');
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
expect(listener).toHaveBeenCalledWith('modechange', { oldMode: 'view', newMode: 'select' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode does nothing if already in that mode', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to view clears selection', () => {
|
||||||
|
// Use select() to properly set up, which also transitions to 'select' mode
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.selectedRoomId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selection', () => {
|
||||||
|
it('select sets selection properties', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.selectedObject).toBe(mesh);
|
||||||
|
expect(interaction.selectedRoomId).toBe('room-1');
|
||||||
|
expect(interaction.selectedIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select emits select event', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith('select', expect.objectContaining({
|
||||||
|
roomId: 'room-1',
|
||||||
|
index: 0,
|
||||||
|
catalogId: 'chair'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select switches mode from view to select', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting same object does nothing', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.select(mesh); // same mesh
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSelection resets state and emits deselect', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.clearSelection();
|
||||||
|
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.selectedRoomId).toBeNull();
|
||||||
|
expect(interaction.selectedIndex).toBe(-1);
|
||||||
|
expect(listener).toHaveBeenCalledWith('deselect', { roomId: 'room-1', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSelection does nothing with no selection', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.clearSelection();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
function keyDown(key, opts = {}) {
|
||||||
|
interaction._onKeyDown({
|
||||||
|
key,
|
||||||
|
ctrlKey: opts.ctrl || false,
|
||||||
|
metaKey: opts.meta || false,
|
||||||
|
shiftKey: opts.shift || false,
|
||||||
|
target: { tagName: opts.tagName || 'BODY' },
|
||||||
|
preventDefault: vi.fn()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Ctrl+Z triggers undo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
keyDown('z', { ctrl: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+Z triggers redo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
keyDown('Z', { ctrl: true, shift: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Y triggers redo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
keyDown('y', { ctrl: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape clears selection and returns to view mode', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('Escape');
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.mode).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Delete removes selected furniture', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('Delete');
|
||||||
|
expect(state.getRoomFurniture('room-1')).toHaveLength(1);
|
||||||
|
// The chair (index 0) was removed; table remains
|
||||||
|
expect(state.getFurniture('room-1', 0).catalogId).toBe('table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R rotates selected furniture by -90 degrees', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('r');
|
||||||
|
expect(state.getFurniture('room-1', 0).rotation).toBe(270); // (0 + (-90) + 360) % 360
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+R rotates selected furniture by +90 degrees', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('R', { shift: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).rotation).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores shortcuts when focused on input', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
state.onChange(listener);
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
listener.mockClear();
|
||||||
|
|
||||||
|
keyDown('z', { ctrl: true, tagName: 'INPUT' });
|
||||||
|
// Undo should NOT have fired
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selection-requiring shortcuts do nothing without selection', () => {
|
||||||
|
keyDown('Delete');
|
||||||
|
keyDown('r');
|
||||||
|
// Should not throw, no furniture removed
|
||||||
|
expect(state.getRoomFurniture('room-1')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onChange / observer', () => {
|
||||||
|
it('registers and unregisters listeners', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = interaction.onChange(listener);
|
||||||
|
interaction.setMode('select');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snap settings', () => {
|
||||||
|
it('defaults to snap enabled at 0.25m', () => {
|
||||||
|
expect(interaction.snapEnabled).toBe(true);
|
||||||
|
expect(interaction.snapSize).toBe(0.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildRoomBounds', () => {
|
||||||
|
it('populates room bounds from house data', () => {
|
||||||
|
interaction._buildRoomBounds();
|
||||||
|
const bounds = interaction._roomBounds.get('room-1');
|
||||||
|
expect(bounds).toEqual({
|
||||||
|
minX: 0, maxX: 5,
|
||||||
|
minZ: 0, maxZ: 5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing house data', () => {
|
||||||
|
mockRenderer.houseData = null;
|
||||||
|
interaction._buildRoomBounds();
|
||||||
|
expect(interaction._roomBounds.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('does not throw', () => {
|
||||||
|
expect(() => interaction.dispose()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
tests/renderer.test.js
Normal file
260
tests/renderer.test.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { HouseRenderer, COLORS } from '../src/renderer.js';
|
||||||
|
|
||||||
|
// Mock container with minimal DOM-like interface
|
||||||
|
function makeContainer() {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
clientWidth: 800,
|
||||||
|
clientHeight: 600,
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
addEventListener: (type, fn) => {
|
||||||
|
listeners[type] = listeners[type] || [];
|
||||||
|
listeners[type].push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
_listeners: listeners,
|
||||||
|
_fireEvent: (type, detail) => {
|
||||||
|
for (const fn of listeners[type] || []) fn({ detail });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HouseRenderer', () => {
|
||||||
|
let renderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Stub global APIs used by the constructor
|
||||||
|
globalThis.window = globalThis.window || {};
|
||||||
|
globalThis.window.addEventListener = vi.fn();
|
||||||
|
globalThis.window.devicePixelRatio = 1;
|
||||||
|
globalThis.requestAnimationFrame = vi.fn();
|
||||||
|
|
||||||
|
// Stub document.createElement for canvas room labels
|
||||||
|
globalThis.document = globalThis.document || {};
|
||||||
|
globalThis.document.createElement = (tag) => {
|
||||||
|
if (tag === 'canvas') {
|
||||||
|
return {
|
||||||
|
getContext: () => ({
|
||||||
|
font: '',
|
||||||
|
measureText: () => ({ width: 100 }),
|
||||||
|
fillStyle: '',
|
||||||
|
fillRect: () => {},
|
||||||
|
textBaseline: '',
|
||||||
|
textAlign: '',
|
||||||
|
fillText: () => {}
|
||||||
|
}),
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { addEventListener: vi.fn(), click: vi.fn(), type: '', accept: '', files: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer = new HouseRenderer(makeContainer());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('COLORS export', () => {
|
||||||
|
it('exports expected color structure', () => {
|
||||||
|
expect(COLORS.wall).toHaveProperty('exterior');
|
||||||
|
expect(COLORS.wall).toHaveProperty('interior');
|
||||||
|
expect(COLORS.floor).toHaveProperty('tile');
|
||||||
|
expect(COLORS.floor).toHaveProperty('hardwood');
|
||||||
|
expect(COLORS).toHaveProperty('ceiling');
|
||||||
|
expect(COLORS).toHaveProperty('door');
|
||||||
|
expect(COLORS).toHaveProperty('window');
|
||||||
|
expect(COLORS).toHaveProperty('windowFrame');
|
||||||
|
expect(COLORS).toHaveProperty('grid');
|
||||||
|
expect(COLORS).toHaveProperty('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has null data references', () => {
|
||||||
|
expect(renderer.houseData).toBeNull();
|
||||||
|
expect(renderer.catalogData).toBeNull();
|
||||||
|
expect(renderer.designData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on floor 0', () => {
|
||||||
|
expect(renderer.currentFloor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has empty mesh maps', () => {
|
||||||
|
expect(renderer.roomMeshes.size).toBe(0);
|
||||||
|
expect(renderer.furnitureMeshes.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_computeWallSegments', () => {
|
||||||
|
it('returns single full-height segment with no openings', () => {
|
||||||
|
const segments = renderer._computeWallSegments([], 4, 2.6);
|
||||||
|
expect(segments).toHaveLength(1);
|
||||||
|
expect(segments[0]).toEqual({ w: 4, h: 2.6, cx: 2, cy: 1.3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates segments around a door opening', () => {
|
||||||
|
const openings = [{ position: 1, width: 1, height: 2.1, bottom: 0 }];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||||
|
|
||||||
|
// Left of door
|
||||||
|
const left = segments.find(s => s.cx < 1);
|
||||||
|
expect(left).toBeDefined();
|
||||||
|
expect(left.w).toBeCloseTo(1, 1);
|
||||||
|
expect(left.h).toBe(2.6);
|
||||||
|
|
||||||
|
// Above door
|
||||||
|
const above = segments.find(s => s.cy > 2.1);
|
||||||
|
expect(above).toBeDefined();
|
||||||
|
expect(above.w).toBe(1); // door width
|
||||||
|
expect(above.h).toBeCloseTo(0.5, 1);
|
||||||
|
|
||||||
|
// Right of door
|
||||||
|
const right = segments.find(s => s.cx > 2);
|
||||||
|
expect(right).toBeDefined();
|
||||||
|
expect(right.w).toBeCloseTo(2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates segments around a window with sill', () => {
|
||||||
|
const openings = [{
|
||||||
|
position: 1, width: 1.2, height: 1.0,
|
||||||
|
bottom: 0.8, sillHeight: 0.8
|
||||||
|
}];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||||
|
|
||||||
|
// Should have: left, above, below (sill), right
|
||||||
|
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||||
|
|
||||||
|
// Below-sill segment
|
||||||
|
const below = segments.find(s => s.h === 0.8 && s.w === 1.2);
|
||||||
|
expect(below).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple openings', () => {
|
||||||
|
const openings = [
|
||||||
|
{ position: 0.5, width: 0.8, height: 2.1, bottom: 0 },
|
||||||
|
{ position: 2.5, width: 1.0, height: 1.0, bottom: 0.8 }
|
||||||
|
];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 5, 2.6);
|
||||||
|
// Should have segments between and around both openings
|
||||||
|
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFloors / getRooms', () => {
|
||||||
|
it('getFloors returns empty array with no house data', () => {
|
||||||
|
expect(renderer.getFloors()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms returns empty array with no house data', () => {
|
||||||
|
expect(renderer.getRooms()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFloors returns floor list after loading', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [
|
||||||
|
{ id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor', rooms: [] },
|
||||||
|
{ id: 'og', name: 'Obergeschoss', nameEN: 'Upper Floor', rooms: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const floors = renderer.getFloors();
|
||||||
|
expect(floors).toHaveLength(2);
|
||||||
|
expect(floors[0]).toEqual({ index: 0, id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms returns rooms for current floor', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg', name: 'EG', rooms: [
|
||||||
|
{ id: 'r1', name: 'Room1', nameEN: 'R1', type: 'living', dimensions: { width: 4, length: 5 } },
|
||||||
|
{ id: 'r2', name: 'Room2', nameEN: 'R2', type: 'bedroom', dimensions: { width: 3, length: 3 } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
renderer.currentFloor = 0;
|
||||||
|
const rooms = renderer.getRooms();
|
||||||
|
expect(rooms).toHaveLength(2);
|
||||||
|
expect(rooms[0].id).toBe('r1');
|
||||||
|
expect(rooms[0].area).toBe(20);
|
||||||
|
expect(rooms[1].area).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms handles invalid floor index', () => {
|
||||||
|
renderer.houseData = { floors: [] };
|
||||||
|
renderer.currentFloor = 5;
|
||||||
|
expect(renderer.getRooms()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setControlsEnabled', () => {
|
||||||
|
it('toggles controls.enabled', () => {
|
||||||
|
renderer.setControlsEnabled(false);
|
||||||
|
expect(renderer.controls.enabled).toBe(false);
|
||||||
|
renderer.setControlsEnabled(true);
|
||||||
|
expect(renderer.controls.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showFloor', () => {
|
||||||
|
it('updates currentFloor', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [
|
||||||
|
{ id: 'eg', name: 'EG', ceilingHeight: 2.6, rooms: [] },
|
||||||
|
{ id: 'og', name: 'OG', ceilingHeight: 2.5, rooms: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
renderer.showFloor(1);
|
||||||
|
expect(renderer.currentFloor).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildFurnitureMesh', () => {
|
||||||
|
it('returns group for catalog item with box parts', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'box', size: [1, 0.5, 0.5], position: [0, 0.25, 0], color: '#8b4513' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns group for cylinder parts', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'cylinder', radius: 0.1, height: 0.7, position: [0, 0.35, 0], color: '#333' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty group for missing mesh def', () => {
|
||||||
|
const group = renderer._buildFurnitureMesh({ id: 'no-mesh' });
|
||||||
|
expect(group.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown geometry types', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'sphere', radius: 0.5, position: [0, 0, 0], color: '#fff' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/setup.js
Normal file
10
tests/setup.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Polyfill browser globals missing in Node.js
|
||||||
|
|
||||||
|
if (typeof globalThis.CustomEvent === 'undefined') {
|
||||||
|
globalThis.CustomEvent = class CustomEvent {
|
||||||
|
constructor(type, opts = {}) {
|
||||||
|
this.type = type;
|
||||||
|
this.detail = opts.detail || null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
347
tests/state.test.js
Normal file
347
tests/state.test.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
|
||||||
|
function makeDesign() {
|
||||||
|
return {
|
||||||
|
name: 'Test Design',
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
roomId: 'room-a',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'chair-1', position: { x: 1, z: 2 }, rotation: 0 },
|
||||||
|
{ catalogId: 'table-1', position: { x: 3, z: 4 }, rotation: 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roomId: 'room-b',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'sofa-1', position: { x: 0, z: 0 }, rotation: 180 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DesignState', () => {
|
||||||
|
let state;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = new DesignState(makeDesign());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Read operations ---
|
||||||
|
|
||||||
|
describe('read operations', () => {
|
||||||
|
it('returns the full design via .design', () => {
|
||||||
|
expect(state.design.name).toBe('Test Design');
|
||||||
|
expect(state.design.rooms).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomDesign returns room by id', () => {
|
||||||
|
const room = state.getRoomDesign('room-a');
|
||||||
|
expect(room).toBeDefined();
|
||||||
|
expect(room.roomId).toBe('room-a');
|
||||||
|
expect(room.furniture).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomDesign returns undefined for missing room', () => {
|
||||||
|
expect(state.getRoomDesign('nonexistent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns specific item', () => {
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.catalogId).toBe('chair-1');
|
||||||
|
expect(item.position).toEqual({ x: 1, z: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns undefined for invalid index', () => {
|
||||||
|
expect(state.getFurniture('room-a', 99)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns undefined for missing room', () => {
|
||||||
|
expect(state.getFurniture('nonexistent', 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomFurniture returns array for valid room', () => {
|
||||||
|
const furniture = state.getRoomFurniture('room-a');
|
||||||
|
expect(furniture).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomFurniture returns empty array for missing room', () => {
|
||||||
|
expect(state.getRoomFurniture('nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Write operations ---
|
||||||
|
|
||||||
|
describe('updateFurniture', () => {
|
||||||
|
it('merges changes into furniture item', () => {
|
||||||
|
state.updateFurniture('room-a', 0, { color: 'red', rotation: 45 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.color).toBe('red');
|
||||||
|
expect(item.rotation).toBe(45);
|
||||||
|
// original fields preserved
|
||||||
|
expect(item.catalogId).toBe('chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid room', () => {
|
||||||
|
expect(() => state.updateFurniture('bad', 0, {})).toThrow('Room not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid index', () => {
|
||||||
|
expect(() => state.updateFurniture('room-a', 99, {})).toThrow('Furniture not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveFurniture', () => {
|
||||||
|
it('updates position', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10, z: 20 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.position.x).toBe(10);
|
||||||
|
expect(item.position.z).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial position update (only x)', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.position.x).toBe(99);
|
||||||
|
expect(item.position.z).toBe(2); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotateFurniture', () => {
|
||||||
|
it('sets rotation', () => {
|
||||||
|
state.rotateFurniture('room-a', 0, 270);
|
||||||
|
expect(state.getFurniture('room-a', 0).rotation).toBe(270);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addFurniture', () => {
|
||||||
|
it('adds to existing room and returns new index', () => {
|
||||||
|
const idx = state.addFurniture('room-a', {
|
||||||
|
catalogId: 'lamp-1', position: { x: 5, z: 5 }, rotation: 0
|
||||||
|
});
|
||||||
|
expect(idx).toBe(2);
|
||||||
|
expect(state.getRoomFurniture('room-a')).toHaveLength(3);
|
||||||
|
expect(state.getFurniture('room-a', 2).catalogId).toBe('lamp-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates room entry if it does not exist', () => {
|
||||||
|
const idx = state.addFurniture('room-new', {
|
||||||
|
catalogId: 'desk-1', position: { x: 0, z: 0 }, rotation: 0
|
||||||
|
});
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
const room = state.getRoomDesign('room-new');
|
||||||
|
expect(room).toBeDefined();
|
||||||
|
expect(room.furniture).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep clones the placement (no aliasing)', () => {
|
||||||
|
const placement = { catalogId: 'x', position: { x: 1, z: 1 }, rotation: 0 };
|
||||||
|
state.addFurniture('room-a', placement);
|
||||||
|
placement.position.x = 999;
|
||||||
|
expect(state.getFurniture('room-a', 2).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeFurniture', () => {
|
||||||
|
it('removes and returns the item', () => {
|
||||||
|
const removed = state.removeFurniture('room-a', 0);
|
||||||
|
expect(removed.catalogId).toBe('chair-1');
|
||||||
|
expect(state.getRoomFurniture('room-a')).toHaveLength(1);
|
||||||
|
// remaining item shifted down
|
||||||
|
expect(state.getFurniture('room-a', 0).catalogId).toBe('table-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid room', () => {
|
||||||
|
expect(state.removeFurniture('bad', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for out-of-range index', () => {
|
||||||
|
expect(state.removeFurniture('room-a', -1)).toBeNull();
|
||||||
|
expect(state.removeFurniture('room-a', 99)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadDesign', () => {
|
||||||
|
it('replaces entire design', () => {
|
||||||
|
const newDesign = { name: 'New', rooms: [{ roomId: 'r1', furniture: [] }] };
|
||||||
|
state.loadDesign(newDesign);
|
||||||
|
expect(state.design.name).toBe('New');
|
||||||
|
expect(state.design.rooms).toHaveLength(1);
|
||||||
|
expect(state.getRoomDesign('room-a')).toBeUndefined();
|
||||||
|
expect(state.getRoomDesign('r1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep clones the loaded design', () => {
|
||||||
|
const newDesign = { name: 'New', rooms: [] };
|
||||||
|
state.loadDesign(newDesign);
|
||||||
|
newDesign.name = 'Mutated';
|
||||||
|
expect(state.design.name).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Undo / Redo ---
|
||||||
|
|
||||||
|
describe('undo / redo', () => {
|
||||||
|
it('initially cannot undo or redo', () => {
|
||||||
|
expect(state.canUndo).toBe(false);
|
||||||
|
expect(state.canRedo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can undo after a mutation', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
expect(state.canUndo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo reverts last mutation', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo re-applies after undo', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
state.redo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo returns false when empty', () => {
|
||||||
|
expect(state.undo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo returns false when empty', () => {
|
||||||
|
expect(state.redo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new mutation clears redo stack', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10 });
|
||||||
|
state.undo();
|
||||||
|
expect(state.canRedo).toBe(true);
|
||||||
|
state.rotateFurniture('room-a', 0, 45);
|
||||||
|
expect(state.canRedo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple undos walk back through history', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10 });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 20 });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 30 });
|
||||||
|
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(20);
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(10);
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects max undo limit', () => {
|
||||||
|
// Default _maxUndo is 50
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
state.moveFurniture('room-a', 0, { x: i });
|
||||||
|
}
|
||||||
|
// Should have at most 50 undo entries
|
||||||
|
let count = 0;
|
||||||
|
while (state.canUndo) {
|
||||||
|
state.undo();
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
expect(count).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Observers ---
|
||||||
|
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('fires listener on mutation', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
state.onChange(listener);
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
expect(listener).toHaveBeenCalledWith('furniture-move', { roomId: 'room-a', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires correct event types', () => {
|
||||||
|
const events = [];
|
||||||
|
state.onChange((type) => events.push(type));
|
||||||
|
|
||||||
|
state.updateFurniture('room-a', 0, { color: 'blue' });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
state.rotateFurniture('room-a', 0, 90);
|
||||||
|
state.addFurniture('room-a', { catalogId: 'x', position: { x: 0, z: 0 }, rotation: 0 });
|
||||||
|
state.removeFurniture('room-a', 0);
|
||||||
|
state.loadDesign({ name: 'n', rooms: [{ roomId: 'room-a', furniture: [] }] });
|
||||||
|
state.undo();
|
||||||
|
state.redo();
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
'furniture-update', 'furniture-move', 'furniture-rotate',
|
||||||
|
'furniture-add', 'furniture-remove', 'design-load',
|
||||||
|
'undo', 'redo'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unsubscribe function', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = state.onChange(listener);
|
||||||
|
unsub();
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches listener errors without breaking', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const badListener = () => { throw new Error('boom'); };
|
||||||
|
const goodListener = vi.fn();
|
||||||
|
|
||||||
|
state.onChange(badListener);
|
||||||
|
state.onChange(goodListener);
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
|
||||||
|
expect(goodListener).toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Serialization ---
|
||||||
|
|
||||||
|
describe('toJSON', () => {
|
||||||
|
it('returns a deep clone of state', () => {
|
||||||
|
const json = state.toJSON();
|
||||||
|
expect(json.name).toBe('Test Design');
|
||||||
|
// mutating the clone shouldn't affect state
|
||||||
|
json.name = 'Mutated';
|
||||||
|
expect(state.design.name).toBe('Test Design');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves structure after mutations', () => {
|
||||||
|
state.addFurniture('room-a', { catalogId: 'new', position: { x: 0, z: 0 }, rotation: 0 });
|
||||||
|
const json = state.toJSON();
|
||||||
|
const room = json.rooms.find(r => r.roomId === 'room-a');
|
||||||
|
expect(room.furniture).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Constructor edge cases ---
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('deep clones initial design (no aliasing)', () => {
|
||||||
|
const design = makeDesign();
|
||||||
|
const s = new DesignState(design);
|
||||||
|
design.rooms[0].furniture[0].position.x = 999;
|
||||||
|
expect(s.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty rooms array', () => {
|
||||||
|
const s = new DesignState({ name: 'Empty', rooms: [] });
|
||||||
|
expect(s.design.rooms).toHaveLength(0);
|
||||||
|
expect(s.getRoomDesign('any')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null/undefined state gracefully in _rebuildIndex', () => {
|
||||||
|
const s = new DesignState({ name: 'No rooms' });
|
||||||
|
expect(s.getRoomDesign('any')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
tests/themes.test.js
Normal file
117
tests/themes.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { COLORS } from '../src/renderer.js';
|
||||||
|
import { ThemeManager } from '../src/themes.js';
|
||||||
|
import { Scene, Color } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
// Snapshot of original COLORS to restore between tests (shared mutable state)
|
||||||
|
const ORIGINAL_COLORS = {
|
||||||
|
wall: { ...COLORS.wall },
|
||||||
|
floor: { ...COLORS.floor },
|
||||||
|
ceiling: COLORS.ceiling,
|
||||||
|
door: COLORS.door,
|
||||||
|
window: COLORS.window,
|
||||||
|
windowFrame: COLORS.windowFrame,
|
||||||
|
grid: COLORS.grid,
|
||||||
|
selected: COLORS.selected
|
||||||
|
};
|
||||||
|
|
||||||
|
function restoreColors() {
|
||||||
|
Object.assign(COLORS.wall, ORIGINAL_COLORS.wall);
|
||||||
|
Object.assign(COLORS.floor, ORIGINAL_COLORS.floor);
|
||||||
|
COLORS.ceiling = ORIGINAL_COLORS.ceiling;
|
||||||
|
COLORS.door = ORIGINAL_COLORS.door;
|
||||||
|
COLORS.window = ORIGINAL_COLORS.window;
|
||||||
|
COLORS.windowFrame = ORIGINAL_COLORS.windowFrame;
|
||||||
|
COLORS.grid = ORIGINAL_COLORS.grid;
|
||||||
|
COLORS.selected = ORIGINAL_COLORS.selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
const scene = new Scene();
|
||||||
|
// Add mock lights to the scene
|
||||||
|
scene.add({ isAmbientLight: true, intensity: 0.6, traverse: (fn) => fn({ isAmbientLight: true, intensity: 0.6 }) });
|
||||||
|
scene.add({ isDirectionalLight: true, intensity: 0.8, traverse: (fn) => fn({ isDirectionalLight: true, intensity: 0.8 }) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
currentFloor: 0,
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
showFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemeManager', () => {
|
||||||
|
let tm, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreColors();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
tm = new ThemeManager(mockRenderer);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreColors();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getThemes', () => {
|
||||||
|
it('returns array of theme descriptors', () => {
|
||||||
|
const themes = tm.getThemes();
|
||||||
|
expect(themes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const theme of themes) {
|
||||||
|
expect(theme).toHaveProperty('id');
|
||||||
|
expect(theme).toHaveProperty('name');
|
||||||
|
expect(theme).toHaveProperty('swatch');
|
||||||
|
expect(typeof theme.id).toBe('string');
|
||||||
|
expect(typeof theme.name).toBe('string');
|
||||||
|
expect(theme.swatch).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes expected theme ids', () => {
|
||||||
|
const ids = tm.getThemes().map(t => t.id);
|
||||||
|
expect(ids).toContain('default');
|
||||||
|
expect(ids).toContain('modern');
|
||||||
|
expect(ids).toContain('warm');
|
||||||
|
expect(ids).toContain('dark');
|
||||||
|
expect(ids).toContain('scandinavian');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('starts with default theme', () => {
|
||||||
|
expect(tm.currentTheme).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTheme', () => {
|
||||||
|
it('updates currentTheme property', () => {
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(tm.currentTheme).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for invalid theme id', () => {
|
||||||
|
tm.applyTheme('nonexistent');
|
||||||
|
expect(tm.currentTheme).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates the shared COLORS object', () => {
|
||||||
|
const origExterior = COLORS.wall.exterior;
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(COLORS.wall.exterior).toBe(0x3a3a3a);
|
||||||
|
// Restore for other tests
|
||||||
|
tm.applyTheme('default');
|
||||||
|
expect(COLORS.wall.exterior).toBe(origExterior);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls _clearFloor and showFloor on renderer', () => {
|
||||||
|
tm.applyTheme('modern');
|
||||||
|
expect(mockRenderer._clearFloor).toHaveBeenCalled();
|
||||||
|
expect(mockRenderer.showFloor).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates scene background', () => {
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(mockRenderer.scene.background._hex).toBe(0x222222);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
vitest.config.js
Normal file
16
vitest.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.js'],
|
||||||
|
setupFiles: ['tests/setup.js'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'three/addons/controls/OrbitControls.js': resolve('tests/__mocks__/OrbitControls.js'),
|
||||||
|
'three': resolve('tests/__mocks__/three.js'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user