Compare commits

..

20 Commits

Author SHA1 Message Date
m
6e498818a7 Add floor plan import design doc and project documentation 2026-02-07 16:39:40 +01:00
m
d53686ed65 Add floor plan image import via LLM vision APIs
New FloorplanImporter module that accepts floor plan images (architect
drawings, realtor photos, hand sketches) and uses Claude or OpenAI vision
APIs to convert them into the project's house JSON format for immediate
3D viewing.

Features:
- Drag-and-drop image upload with preview
- Multi-provider support (Claude Sonnet, GPT-4o)
- Canvas preprocessing (resize to 2048px max)
- Structured prompt engineering for accurate room extraction
- JSON validation and auto-repair of common LLM output issues
- Result preview showing rooms, doors, windows counts
- Inline JSON editor for manual corrections
- API key persistence in localStorage
- Integrated into sidebar File section as "Import Floor Plan" button
2026-02-07 16:38:14 +01:00
m
53999728c4 Add 3 example houses: small apartment, modern loft, large villa
- Small apartment (60sqm): 5 rooms, single floor compact city layout
- Modern loft (80sqm): 6 rooms, open-plan living with high ceilings
- Large villa (300sqm): 15 rooms across 2 floors with luxury finishes

Each house includes both structure definition and furnished design
with realistic furniture placement using the existing catalog.
2026-02-07 16:38:01 +01:00
m
8ac5b3f1f9 Add test suite with 132 unit tests across all modules
Covers DesignState (40 tests), HouseRenderer (19), InteractionManager (24),
ThemeManager (8), ExportManager (11), and CatalogPanel (30). Uses vitest
with THREE.js mocks for browser-free testing.
2026-02-07 16:34:36 +01:00
m
bc94d41f2b Add house customization UI with create/edit houses and room management
Adds HouseEditor panel to the right sidebar with:
- Edit house name and description
- Adjust building footprint dimensions
- Add/remove floors with ceiling height control
- Add/remove rooms with name, type, and dimensions
- Edit selected room: name, type, dimensions, position, flooring
- Save house as JSON template
- Create new empty house from scratch
2026-02-07 16:31:55 +01:00
m
4ca495209d Add screenshot showing IKEA catalog and custom furniture features 2026-02-07 12:59:53 +01:00
m
ceea42ac1d Add IKEA furniture catalog with 41 items and tabbed browse UI
- Create data/ikea-catalog.json with 41 curated IKEA items across 23 series
  (KALLAX, BILLY, MALM, PAX, HEMNES, LACK, etc.) with verified dimensions
- Add source tabs (All/Standard/IKEA) to catalog panel for filtering
- Add IKEA series filter bar when viewing IKEA items
- Add IKEA badge and series label on item cards
- Add mergeCatalog() to renderer for loading additional catalog files
- Add scripts/import-ikea-hf.js for importing from HuggingFace dataset
2026-02-07 12:58:52 +01:00
m
cf0fe586eb Add IKEA furniture data research report 2026-02-07 12:50:33 +01:00
m
53ee0fc1ec Add custom furniture creator form to catalog panel
Adds a "Create Custom Furniture" button at the bottom of the catalog
sidebar that expands into a form with fields for name, dimensions
(W/D/H in meters), color picker, and category selector. Submitting
creates a simple box-geometry catalog item and adds it to the
in-memory catalog for immediate placement into rooms.
2026-02-07 12:45:09 +01:00
m
d35b61648e Add screenshot of 3D house viewer with furniture 2026-02-07 12:40:43 +01:00
m
ab3e8fd03c Add research and interactive features design docs
- RESEARCH.md: Tech stack analysis (Three.js, SVG, hybrid approaches)
- DESIGN-interactive-features.md: Phase 2 design with 5 sprints, 14 tasks
2026-02-07 12:31:08 +01:00
m
32eaf70635 Add catalog sidebar panel with categories, search, and click-to-place 2026-02-07 12:27:11 +01:00
m
e10abf4cf3 Add preserveDrawingBuffer for reliable PNG screenshot export
Without this flag, toDataURL() on the WebGL canvas can return blank
in some browsers because the drawing buffer gets cleared.
2026-02-07 12:26:30 +01:00
m
4d4d5f947b Add JSON save/load and PNG screenshot export
- ExportManager with JSON export (toJSON + timestamp), import with
  file picker and validation, and high-res PNG screenshot capture
- Save/Load/Screenshot buttons in sidebar
- Ctrl+S keyboard shortcut for quick save
- Design load event updates info bar
2026-02-07 12:26:05 +01:00
m
08248c6cad Add theme system with 5 presets and selector UI
- 5 themes: Standard, Modern, Warm, Dark, Scandinavian
- ThemeManager mutates shared COLORS, clears cache, re-renders
- Updates scene background and light intensities per theme
- Theme selector buttons with color swatch in sidebar
- Exported COLORS from renderer.js for theme access
2026-02-07 12:24:58 +01:00
m
c0368f9f01 Add drag-to-move furniture with grid snapping and room bounds
- Pointer down on selected furniture starts drag mode
- OrbitControls disabled during drag
- Floor plane (Y=0) raycasting for cursor projection
- Drag offset preserves grab point (no cursor jump)
- Grid snapping at 0.25m intervals (configurable)
- Room bounds constraint keeps furniture inside walls
- On pointer up, commits new position to DesignState
- Wall-mounted items excluded from drag
2026-02-07 12:23:27 +01:00
m
d0d9deb03a Add InteractionManager with mode system, selection outline, keyboard shortcuts
- Mode system: view | select | move | rotate | place
- Click furniture to select with cyan wireframe outline
- Keyboard: R/Shift+R rotate, Delete remove, Escape deselect,
  Ctrl+Z undo, Ctrl+Shift+Z/Ctrl+Y redo
- Listens to DesignState changes to sync scene on undo/redo
- Wired into index.html with DesignState integration
- Added furnitureIndex to mesh userData for state lookups
2026-02-07 12:22:06 +01:00
m
36bc0aedd7 Add DesignState class with observable state and undo/redo
Lightweight state management for furniture editing. All mutations go
through named methods that snapshot state for undo, then notify
listeners. Supports move, rotate, add, remove, full design load,
and serialization via toJSON().
2026-02-07 12:20:03 +01:00
m
35300aa57a Add furniture click events, OrbitControls toggle, and floor change event
- Export COLORS for external module access (themes)
- Extend _onClick to detect furniture clicks before room clicks,
  dispatching 'furnitureclick' with catalog/room/mesh details
- Add setControlsEnabled(enabled) to toggle OrbitControls
- Dispatch 'floorchange' event from showFloor()
- Wire up furnitureclick in index.html to show item info
2026-02-07 12:19:19 +01:00
m
bf4eee8595 Improve 3D renderer: shadow camera, caching, error handling, labels
Renderer improvements:
- Configure shadow camera frustum to cover full house (was default -5..5)
- Add geometry and material caching to reduce GPU allocations
- Add proper disposal of Three.js objects on floor switch (fix memory leak)
- Add error handling for fetch failures with custom event dispatch
- Add room labels as sprites floating in each room
- Support wall-mounted furniture Y positioning via position.y
- Use cached highlight materials instead of mutating shared materials
2026-02-07 12:02:04 +01:00
39 changed files with 12166 additions and 70 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.maiproject/CLAUDE.md

1
AI.md Symbolic link
View File

@@ -0,0 +1 @@
.maiproject/CLAUDE.md

View File

@@ -0,0 +1,985 @@
# Interactive Features Design — Phase 2
**Task:** t-05700
**Date:** 2026-02-07
**Role:** Inventor
## Overview
Four interactive feature areas to transform the viewer into an editor:
1. **Drag-and-drop furniture** — move, rotate, place from catalog
2. **Room editing** — resize rooms, edit wall openings
3. **Style themes** — switchable color/material palettes
4. **Export** — save designs, screenshot, share
Design principle: **Enhance the existing vanilla JS + Three.js stack**. No framework rewrite. Each feature is an independent module that plugs into `HouseRenderer`.
---
## Architecture Strategy
### Module Structure
```
src/
renderer.js (existing — 3D core)
index.html (existing — entry point)
interaction.js (NEW — drag/drop, selection, gizmos)
room-editor.js (NEW — room resize, wall editing)
themes.js (NEW — style theme system)
export.js (NEW — save/export functionality)
ui-panels.js (NEW — sidebar panels, catalog browser, property inspector)
```
Each module exports a class that receives the `HouseRenderer` instance and extends it via composition (not inheritance). This keeps renderer.js stable while adding capabilities.
```javascript
// Pattern for all modules:
export class InteractionManager {
constructor(renderer) {
this.renderer = renderer;
// Hook into renderer's scene, camera, controls
}
dispose() { /* cleanup */ }
}
```
### State Management
Currently state lives in scattered instance variables. For interactive editing, we need a lightweight state layer:
```javascript
// src/state.js — simple observable state
export class DesignState {
constructor(initialDesign) {
this._state = structuredClone(initialDesign);
this._listeners = new Set();
this._undoStack = [];
this._redoStack = [];
}
// Read
get design() { return this._state; }
getRoomDesign(roomId) { ... }
getFurniture(roomId, index) { ... }
// Write (all mutations go through here)
updateFurniture(roomId, index, changes) {
this._pushUndo();
Object.assign(this._state.rooms[...].furniture[index], changes);
this._notify('furniture-update', { roomId, index });
}
moveFurniture(roomId, index, newPosition) { ... }
rotateFurniture(roomId, index, degrees) { ... }
addFurniture(roomId, catalogId, position, rotation) { ... }
removeFurniture(roomId, index) { ... }
// Undo/Redo
undo() { ... }
redo() { ... }
// Observers
onChange(listener) { this._listeners.add(listener); }
_notify(type, detail) { for (const fn of this._listeners) fn(type, detail); }
_pushUndo() { this._undoStack.push(structuredClone(this._state)); this._redoStack = []; }
}
```
This is intentionally minimal — no Redux, no Zustand, just a class with undo/redo.
---
## Feature 1: Drag-and-Drop Furniture
### Interaction Modes
The viewer operates in one of these modes:
| Mode | Behavior | Activation |
|------|----------|------------|
| **View** | Current behavior — orbit, zoom, click rooms | Default |
| **Select** | Click furniture to select, show properties | Toggle button |
| **Move** | Drag selected furniture on floor plane | Select + drag |
| **Rotate** | Rotate selected furniture around Y axis | R key or gizmo |
| **Place** | Drag new item from catalog into scene | Catalog click |
### Selection System
```javascript
class InteractionManager {
constructor(renderer) {
this.renderer = renderer;
this.selectedObject = null;
this.mode = 'view'; // view | select | move | rotate | place
this.dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // floor plane
this.dragOffset = new THREE.Vector3();
this._ghostMesh = null; // preview during placement
// Event listeners
this.renderer.renderer.domElement.addEventListener('pointerdown', e => this._onPointerDown(e));
this.renderer.renderer.domElement.addEventListener('pointermove', e => this._onPointerMove(e));
this.renderer.renderer.domElement.addEventListener('pointerup', e => this._onPointerUp(e));
window.addEventListener('keydown', e => this._onKeyDown(e));
}
}
```
### Furniture Selection
When user clicks on a furniture object:
1. Raycast from camera through click point
2. Walk hit object up to find `userData.isFurniture` group
3. Apply selection highlight (outline or emissive tint)
4. Show property panel in sidebar
5. Enable move/rotate controls
**Selection visual:** Outline effect using a slightly scaled-up wireframe clone with a distinct color (e.g., cyan). This avoids modifying original materials.
```javascript
_selectFurniture(meshGroup) {
this._clearSelection();
this.selectedObject = meshGroup;
// Create outline by cloning with wireframe material
const outline = meshGroup.clone();
outline.traverse(child => {
if (child.isMesh) {
child.material = this._outlineMaterial; // cyan wireframe, slightly larger scale
child.scale.multiplyScalar(1.02);
}
});
outline.userData._isOutline = true;
meshGroup.add(outline);
}
```
### Drag-to-Move
When user drags a selected furniture piece:
1. On `pointerdown`: if hitting selected object, enter move mode
2. Disable OrbitControls (prevent camera rotation during drag)
3. Raycast pointer against floor plane (Y=0) each frame
4. Apply offset so object doesn't jump to cursor
5. Optionally snap to grid (0.1m or 0.25m increments)
6. Constrain within room bounds
7. On `pointerup`: commit new position to DesignState, re-enable OrbitControls
```javascript
_onPointerMove(event) {
if (this.mode !== 'move' || !this.selectedObject) return;
const mouse = this._getNDC(event);
this.renderer.raycaster.setFromCamera(mouse, this.renderer.camera);
const intersection = new THREE.Vector3();
this.renderer.raycaster.ray.intersectPlane(this.dragPlane, intersection);
if (intersection) {
// Apply grid snapping
if (this.snapEnabled) {
intersection.x = Math.round(intersection.x / this.snapSize) * this.snapSize;
intersection.z = Math.round(intersection.z / this.snapSize) * this.snapSize;
}
// Apply room bounds constraint
const room = this._getContainingRoom(intersection);
if (room) {
intersection.x = clamp(intersection.x, room.bounds.minX + padding, room.bounds.maxX - padding);
intersection.z = clamp(intersection.z, room.bounds.minZ + padding, room.bounds.maxZ - padding);
}
this.selectedObject.position.copy(intersection.sub(this.dragOffset));
}
}
```
### Rotation
Two mechanisms:
1. **Quick rotate:** Press R key to rotate 90 degrees
2. **Gizmo:** Circular handle at base of selected furniture, drag to rotate freely
Quick rotate is simplest to implement first:
```javascript
_onKeyDown(event) {
if (event.key === 'r' && this.selectedObject) {
this.selectedObject.rotation.y -= Math.PI / 2;
this.state.rotateFurniture(roomId, index, -90);
}
if (event.key === 'Delete' && this.selectedObject) {
this.state.removeFurniture(roomId, index);
this._removeFromScene(this.selectedObject);
this._clearSelection();
}
if (event.key === 'Escape') {
this._clearSelection();
this.mode = 'view';
}
}
```
### Catalog Drag-to-Place
Adding new furniture from the catalog sidebar:
1. User clicks a catalog item in sidebar
2. Create a ghost (semi-transparent) mesh from catalog definition
3. Ghost follows cursor, projected onto floor plane
4. Show green/red tint for valid/invalid placement
5. Click to place — add to DesignState and create real mesh
6. Right-click or Escape to cancel
**Alternative (simpler):** Click catalog item, then click on room floor to place at that point. No drag-from-sidebar needed. This avoids the complexity of HTML-to-3D coordinate mapping.
**Recommendation:** Start with click-to-place, add drag-from-sidebar later.
### Room Boundary Detection
For constraining furniture to rooms, build bounding boxes from room data:
```javascript
_buildRoomBounds() {
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
this.roomBounds = new Map();
for (const room of floor.rooms) {
this.roomBounds.set(room.id, {
minX: room.position.x,
maxX: room.position.x + room.dimensions.width,
minZ: room.position.y,
maxZ: room.position.y + room.dimensions.length
});
}
}
```
### Collision Detection (Optional, Phase 2)
Basic AABB overlap check to prevent furniture stacking:
```javascript
_checkCollision(candidate, exclude) {
const box1 = new THREE.Box3().setFromObject(candidate);
for (const [key, mesh] of this.renderer.furnitureMeshes) {
if (mesh === exclude) continue;
const box2 = new THREE.Box3().setFromObject(mesh);
if (box1.intersectsBox(box2)) return true;
}
return false;
}
```
### Implementation Priority
| Step | What | Complexity | Depends On |
|------|------|------------|------------|
| 1 | Click-to-select furniture | Low | Existing raycaster |
| 2 | Selection outline visual | Low | Step 1 |
| 3 | Property panel in sidebar | Low | Step 1 |
| 4 | Drag-to-move on floor plane | Medium | Step 1, OrbitControls toggle |
| 5 | Grid snapping | Low | Step 4 |
| 6 | Room bounds constraint | Low | Step 4 |
| 7 | R key rotation | Low | Step 1 |
| 8 | Delete key removal | Low | Step 1, DesignState |
| 9 | Catalog click-to-place | Medium | DesignState |
| 10 | Ghost preview during placement | Medium | Step 9 |
| 11 | Undo/redo | Medium | DesignState |
| 12 | Collision detection | Medium | Step 4 |
---
## Feature 2: Room Editing
### Scope
Room editing is more complex than furniture interaction. Recommended phased approach:
**Phase 2a (Do first):** Edit room properties only — name, flooring type, wall colors
**Phase 2b (Later):** Resize rooms — drag walls to change dimensions
**Phase 2c (Future):** Add/remove rooms, edit doors/windows
### Phase 2a: Room Property Editing
When a room is selected, show editable properties in sidebar:
```
Room: Wohnzimmer
────────────────
Name: [Wohnzimmer ]
Type: [Living Room ▾]
Flooring: [Hardwood ▾] [Custom color: #b5894e]
Size: 4.5m × 5.5m (24.8 m²)
Ceiling: 2.6m
Walls:
North: [Interior ▾] — Door to Esszimmer
South: [Exterior ▾] — Window (1.8m)
East: [Interior ▾] — Door to Flur
West: [Exterior ▾] — Patio door (2.0m)
```
Changes update both DesignState and the 3D scene in real-time.
### Phase 2b: Room Resize
Allow dragging room edges to resize:
1. When room is selected, show drag handles on each wall midpoint
2. Dragging a handle moves that wall, changing room width or length
3. Adjacent rooms may need to adjust (constraint system)
4. Minimum room size: 1.5m × 1.5m
5. Grid snap: 0.25m increments
**Wall drag handles:** Small sphere or cube meshes placed at wall midpoints, highlighted on hover.
```javascript
_createResizeHandles(room) {
const handles = [];
const walls = ['north', 'south', 'east', 'west'];
for (const wall of walls) {
const handle = new THREE.Mesh(
new THREE.SphereGeometry(0.1),
new THREE.MeshStandardMaterial({ color: 0x4a90d9 })
);
handle.userData = { isHandle: true, wall, roomId: room.id };
// Position at wall midpoint
handles.push(handle);
}
return handles;
}
```
**Constraint challenge:** When room A's east wall moves, room B's west wall (if adjacent) should also move. This requires understanding room adjacency from door connections. The house data already has `connectsTo` fields on doors — use these to build an adjacency graph.
### Phase 2c: Door/Window Editing (Future)
- Click wall to add/remove doors or windows
- Drag door/window along wall to reposition
- Change door/window types from property panel
- This modifies `sample-house.json` structure
**Recommendation:** Defer this to a future phase. It's architecturally complex (wall segmentation recalculation, connectivity validation) and the current house data is well-defined.
### Implementation Priority
| Step | What | Complexity |
|------|------|------------|
| 1 | Room property panel (read-only) | Low |
| 2 | Editable flooring type | Low |
| 3 | Editable room name | Low |
| 4 | Wall color customization | Medium |
| 5 | Room resize handles | High |
| 6 | Adjacent room constraint system | High |
| 7 | Door/window editing | Very High |
---
## Feature 3: Style Themes
### Concept
Predefined color/material palettes that restyle the entire house. Themes override the `COLORS` object and material properties.
### Theme Data Structure
```javascript
// src/themes.js
export const THEMES = {
default: {
name: 'Standard',
colors: {
wall: { exterior: 0xe8e0d4, interior: 0xf5f0eb },
floor: { tile: 0xc8beb0, hardwood: 0xb5894e },
ceiling: 0xfaf8f5,
door: 0x8b6914,
window: 0x87ceeb,
windowFrame: 0xd0d0d0,
grid: 0xcccccc,
selected: 0x4a90d9
},
materials: {
wallRoughness: 0.9,
floorRoughness: 0.8,
doorRoughness: 0.6
},
scene: {
background: 0xf0f0f0,
ambientIntensity: 0.6,
directionalIntensity: 0.8
}
},
modern: {
name: 'Modern Minimal',
colors: {
wall: { exterior: 0xf5f5f5, interior: 0xffffff },
floor: { tile: 0xe0e0e0, hardwood: 0xc4a882 },
ceiling: 0xffffff,
door: 0x333333,
window: 0xa8d4f0,
windowFrame: 0x666666,
grid: 0xe0e0e0,
selected: 0x2196f3
},
materials: { wallRoughness: 0.3, floorRoughness: 0.4, doorRoughness: 0.2 },
scene: { background: 0xfafafa, ambientIntensity: 0.7, directionalIntensity: 0.6 }
},
warm: {
name: 'Warm Rustic',
colors: {
wall: { exterior: 0xddd0b8, interior: 0xf0e8d8 },
floor: { tile: 0xb8a890, hardwood: 0x9b6b3a },
ceiling: 0xf5efe5,
door: 0x6b4423,
window: 0x8bc4e0,
windowFrame: 0x8b7355,
grid: 0xc8b8a0,
selected: 0xd48b2c
},
materials: { wallRoughness: 0.95, floorRoughness: 0.9, doorRoughness: 0.8 },
scene: { background: 0xf5efe5, ambientIntensity: 0.5, directionalIntensity: 0.9 }
},
dark: {
name: 'Dark Mode',
colors: {
wall: { exterior: 0x3a3a3a, interior: 0x4a4a4a },
floor: { tile: 0x2a2a2a, hardwood: 0x5a4030 },
ceiling: 0x333333,
door: 0x5a4030,
window: 0x4080b0,
windowFrame: 0x555555,
grid: 0x444444,
selected: 0x64b5f6
},
materials: { wallRoughness: 0.7, floorRoughness: 0.6, doorRoughness: 0.5 },
scene: { background: 0x222222, ambientIntensity: 0.4, directionalIntensity: 1.0 }
},
scandinavian: {
name: 'Scandinavian',
colors: {
wall: { exterior: 0xf0ece4, interior: 0xfaf6f0 },
floor: { tile: 0xe8ddd0, hardwood: 0xd4b88c },
ceiling: 0xffffff,
door: 0xc4a87a,
window: 0xc0ddf0,
windowFrame: 0xb0b0b0,
grid: 0xd8d8d8,
selected: 0x5b9bd5
},
materials: { wallRoughness: 0.5, floorRoughness: 0.6, doorRoughness: 0.4 },
scene: { background: 0xf8f6f2, ambientIntensity: 0.65, directionalIntensity: 0.7 }
}
};
```
### Theme Application
```javascript
export class ThemeManager {
constructor(renderer) {
this.renderer = renderer;
this.currentTheme = 'default';
}
applyTheme(themeId) {
const theme = THEMES[themeId];
if (!theme) return;
this.currentTheme = themeId;
// Update COLORS object (renderer reads from this)
Object.assign(COLORS.wall, theme.colors.wall);
Object.assign(COLORS.floor, theme.colors.floor);
COLORS.ceiling = theme.colors.ceiling;
COLORS.door = theme.colors.door;
COLORS.window = theme.colors.window;
COLORS.windowFrame = theme.colors.windowFrame;
// Update scene
this.renderer.scene.background.setHex(theme.scene.background);
// Clear material cache (forces recreation with new colors)
// Then re-render current floor
this.renderer._clearFloor();
this.renderer.showFloor(this.renderer.currentFloor);
}
getThemes() {
return Object.entries(THEMES).map(([id, t]) => ({ id, name: t.name }));
}
}
```
**Key insight:** The renderer already uses a `COLORS` constant and caches materials keyed by color. Changing `COLORS` and clearing the cache forces a full re-render with new colors. This is clean and requires minimal renderer changes — just make `COLORS` mutable (change `const` to `let`, or use an object that can be mutated).
### Renderer Change Required
The `COLORS` constant in renderer.js needs to be accessible for mutation. Two options:
**Option A (minimal):** Export `COLORS` and let ThemeManager mutate it directly.
```javascript
export const COLORS = { ... }; // already an object, properties are mutable
```
**Option B (cleaner):** Add a `setColors(newColors)` method to HouseRenderer.
Recommend Option A for simplicity — `COLORS` is already a mutable object.
### UI: Theme Selector
Add a theme dropdown or button row to the sidebar:
```html
<h3>Theme</h3>
<div id="theme-buttons">
<!-- Generated: one button per theme -->
<button class="theme-btn active" data-theme="default">
<span class="theme-swatch" style="background: #e8e0d4"></span>
Standard
</button>
...
</div>
```
Each theme button shows a small color swatch preview.
### Per-Room Color Override (Stretch)
Allow individual rooms to override theme colors:
```javascript
// In DesignState
roomOverrides: {
"eg-wohnzimmer": {
floorColor: "#a0522d",
wallColor: "#f0e0d0"
}
}
```
The renderer checks for overrides before falling back to theme defaults.
### Implementation Priority
| Step | What | Complexity |
|------|------|------------|
| 1 | Define 5 theme presets | Low |
| 2 | Theme application (clear cache + re-render) | Low |
| 3 | Theme selector UI | Low |
| 4 | Smooth transition (fade between themes) | Medium |
| 5 | Per-room color overrides | Medium |
| 6 | Custom theme builder | High |
---
## Feature 4: Export
### Export Formats
| Format | What | Use Case |
|--------|------|----------|
| **JSON** | Design state (furniture placements) | Save/load, share |
| **PNG** | Screenshot of current view | Quick sharing |
| **PDF** | 2D floor plan with furniture | Printing |
| **glTF** | 3D model of entire house | Use in other 3D apps |
### JSON Save/Load
```javascript
export class ExportManager {
constructor(renderer, state) {
this.renderer = renderer;
this.state = state;
}
// Save design to JSON
exportDesignJSON() {
const data = {
...this.state.design,
exportedAt: new Date().toISOString(),
theme: this.themeManager?.currentTheme || 'default'
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
this._download(blob, `${data.name || 'design'}.json`);
}
// Load design from JSON
async importDesignJSON(file) {
const text = await file.text();
const data = JSON.parse(text);
this.state.loadDesign(data);
this.renderer._clearFloor();
this.renderer.designData = data;
this.renderer._placeFurnitureForFloor();
}
}
```
### PNG Screenshot
Three.js makes this straightforward:
```javascript
exportScreenshot(width = 1920, height = 1080) {
// Temporarily resize renderer for high-res capture
const prevSize = new THREE.Vector2();
this.renderer.renderer.getSize(prevSize);
this.renderer.renderer.setSize(width, height);
this.renderer.camera.aspect = width / height;
this.renderer.camera.updateProjectionMatrix();
this.renderer.renderer.render(this.renderer.scene, this.renderer.camera);
const dataURL = this.renderer.renderer.domElement.toDataURL('image/png');
// Restore original size
this.renderer.renderer.setSize(prevSize.x, prevSize.y);
this.renderer.camera.aspect = prevSize.x / prevSize.y;
this.renderer.camera.updateProjectionMatrix();
this._downloadDataURL(dataURL, 'house-design.png');
}
```
**Enhancement:** Option to hide UI overlays (sidebar, info bar) during capture, or render to an offscreen canvas.
### PDF Floor Plan (Phase 2)
Generate a 2D top-down floor plan suitable for printing:
1. Create a 2D canvas (or use jsPDF)
2. Draw rooms as rectangles with labels
3. Draw furniture as simplified top-down shapes
4. Add dimension lines and measurements
5. Include legend with furniture names
This is a separate rendering pipeline from the 3D view. Consider using the `canvas` API directly or a library like `jspdf` + `html2canvas`.
```javascript
exportFloorPlanPDF() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scale = 50; // 50px per meter
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
// ... draw rooms, furniture, dimensions, legend
// Convert to PDF using jsPDF or similar
}
```
### glTF Export (Phase 3)
Three.js has a built-in `GLTFExporter`:
```javascript
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
exportGLTF() {
const exporter = new GLTFExporter();
exporter.parse(this.renderer.scene, (gltf) => {
const blob = new Blob([JSON.stringify(gltf)], { type: 'application/json' });
this._download(blob, 'house-design.gltf');
}, { binary: false });
// Or binary .glb:
exporter.parse(this.renderer.scene, (buffer) => {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
this._download(blob, 'house-design.glb');
}, { binary: true });
}
```
### Auto-Save
Save design state to `localStorage` periodically:
```javascript
// Every 30 seconds, save to localStorage
setInterval(() => {
localStorage.setItem('house-design-autosave', JSON.stringify(this.state.design));
}, 30000);
// On load, offer to restore
const saved = localStorage.getItem('house-design-autosave');
if (saved) {
// Show "Restore previous session?" prompt
}
```
### Implementation Priority
| Step | What | Complexity |
|------|------|------------|
| 1 | JSON export (download) | Low |
| 2 | JSON import (file picker) | Low |
| 3 | PNG screenshot | Low |
| 4 | Auto-save to localStorage | Low |
| 5 | 2D floor plan PDF | High |
| 6 | glTF/GLB export | Medium |
| 7 | Share via URL (encode state) | Medium |
---
## UI Design
### Updated Sidebar Layout
```
┌─────────────────────────────┐
│ ☰ Musterhaus │
├─────────────────────────────┤
│ [View] [Edit] [Place] │ ← Mode buttons
├─────────────────────────────┤
│ FLOORS │
│ [EG] [OG] │
├─────────────────────────────┤
│ ROOMS │
│ ▸ Flur 18.0 m² │
│ ▸ Wohnzimmer 24.8 m² │ ← Expandable to show furniture
│ ▸ Küche 15.0 m² │
│ ... │
├─────────────────────────────┤
│ CATALOG 🔍 search │ ← Only visible in Place mode
│ ┌──────┐ ┌──────┐ │
│ │ Sofa │ │Chair │ │
│ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │Table │ │ Bed │ │
│ └──────┘ └──────┘ │
├─────────────────────────────┤
│ PROPERTIES │ ← Only visible when item selected
│ Sofa 3-Sitzer │
│ Position: (2.1, 3.4) │
│ Rotation: 180° │
│ [Rotate 90°] [Delete] │
├─────────────────────────────┤
│ THEME │
│ [Standard] [Modern] [Warm] │
│ [Dark] [Scandinavian] │
├─────────────────────────────┤
│ [💾 Save] [📷 Screenshot] │ ← Always visible
│ [📂 Load] [📤 Export 3D] │
└─────────────────────────────┘
```
### Toolbar (Top)
For quick access to common actions:
```
┌─────────────────────────────────────────────────────┐
│ [Undo] [Redo] | [Snap: 0.25m ▾] | [Grid ✓] [Labels ✓] │
└─────────────────────────────────────────────────────┘
```
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Escape` | Cancel current action, deselect |
| `Delete` / `Backspace` | Remove selected furniture |
| `R` | Rotate selected 90° clockwise |
| `Shift+R` | Rotate selected 90° counter-clockwise |
| `Ctrl+Z` | Undo |
| `Ctrl+Shift+Z` | Redo |
| `Ctrl+S` | Save design JSON |
| `G` | Toggle grid |
| `L` | Toggle room labels |
| `1`-`9` | Quick-select room by index |
---
## Required Renderer Changes
To support these features, `HouseRenderer` needs a few additions:
### 1. Make COLORS Exportable and Mutable
```javascript
// Change from const to let, or export the object
export const COLORS = { ... }; // Already works — object properties are mutable
```
### 2. Add Furniture Click Detection
Extend `_onClick` to distinguish room clicks from furniture clicks:
```javascript
_onClick(event) {
// ... existing raycaster setup ...
for (const hit of intersects) {
let obj = hit.object;
// Check furniture first (more specific)
while (obj && !obj.userData.isFurniture && !obj.userData.roomId) {
obj = obj.parent;
}
if (obj?.userData.isFurniture) {
this.container.dispatchEvent(new CustomEvent('furnitureclick', {
detail: { ...obj.userData, mesh: obj, point: hit.point }
}));
return;
}
if (obj?.userData.roomId) {
// existing room click behavior
}
}
}
```
### 3. Add OrbitControls Toggle
```javascript
setControlsEnabled(enabled) {
this.controls.enabled = enabled;
}
```
### 4. Expose Scene for External Modules
The renderer already exposes `this.scene`, `this.camera`, `this.raycaster` as public properties. No changes needed — external modules can access these directly.
### 5. Floor-switch Event
```javascript
showFloor(index) {
// ... existing code ...
this.container.dispatchEvent(new CustomEvent('floorchange', {
detail: { index, floor: this.houseData.floors[index] }
}));
}
```
---
## Trade-off Analysis
### Framework vs. Vanilla JS
**Decision: Stay vanilla JS.**
Pros of staying vanilla:
- No build step needed (works with static file server)
- No dependency management
- Project already works well this way
- Interactive features can be added as ES6 modules
Cons:
- No reactive UI updates (must manually sync DOM)
- No component system for sidebar panels
- State management is DIY
Mitigation: Keep UI simple. Use custom events for cross-module communication. The sidebar can be built with template literals and direct DOM manipulation — it doesn't need React for this complexity level.
### 2D vs. 3D Interaction
**Decision: 3D interaction (existing stack).**
The RESEARCH.md recommended a 2D Konva.js approach, but the project already has a working 3D viewer with raycasting. Adding drag-and-drop to 3D is harder than 2D, but:
1. We already have raycasting and hit detection
2. Floor plane projection for drag is straightforward
3. Users already understand the 3D orbit camera
4. A 2D top-down view can be added later as an alternative mode
### Undo/Redo Approach
**Decision: Full state snapshots with structuredClone.**
Alternative: Command pattern (store individual operations). More memory-efficient but harder to implement correctly.
For a house design with ~50 furniture items, the design JSON is ~10-20KB. Storing 50 undo snapshots = ~1MB. That's fine. Simplicity wins.
### Collision Detection
**Decision: Defer to Phase 2.**
AABB collision detection is useful but not critical for MVP. Users can visually avoid overlaps. Implement only if users find placement confusing without it.
---
## Implementation Roadmap
### Sprint 1: Foundation (3-4 tasks)
1. **DesignState class** — observable state with undo/redo
2. **Renderer events**`furnitureclick`, `floorchange`, controls toggle
3. **InteractionManager skeleton** — mode system, keyboard shortcuts
4. **Selection visual** — outline on selected furniture
### Sprint 2: Drag & Drop (3-4 tasks)
5. **Drag-to-move** — floor plane projection, OrbitControls toggle
6. **Grid snapping** — configurable snap size
7. **Room bounds constraint** — keep furniture in rooms
8. **Rotate & delete** — R key, Delete key
### Sprint 3: Catalog & Placement (2-3 tasks)
9. **Catalog sidebar panel** — browsable, filterable
10. **Click-to-place** — new furniture from catalog
11. **Ghost preview** — semi-transparent placement preview
### Sprint 4: Themes & Export (3-4 tasks)
12. **Theme system** — 5 presets, apply/switch
13. **Theme selector UI**
14. **JSON save/load**
15. **PNG screenshot**
### Sprint 5: Room Editing & Polish (3-4 tasks)
16. **Room property panel** — read-only info
17. **Editable flooring/name**
18. **Auto-save to localStorage**
19. **Toolbar with undo/redo buttons**
### Future Sprints
20. Room resize handles
21. 2D floor plan export (PDF)
22. glTF 3D export
23. Collision detection
24. Per-room color overrides
25. Door/window editing
---
## Task Breakdown for mai
These are the tasks to create for implementation:
```
1. "Create DesignState class with undo/redo" (coder)
2. "Add furniture click events and controls toggle to renderer" (coder)
3. "Build InteractionManager with mode system and keyboard shortcuts" (coder)
4. "Implement furniture selection with outline visual" (coder)
5. "Add drag-to-move furniture on floor plane" (coder)
6. "Add grid snapping and room bounds constraint" (coder)
7. "Build catalog sidebar panel with categories and search" (coder)
8. "Implement click-to-place new furniture from catalog" (coder)
9. "Create theme system with 5 presets" (coder)
10. "Add theme selector UI to sidebar" (coder)
11. "Implement JSON save/load and PNG screenshot export" (coder)
12. "Add room property panel and editable properties" (coder)
13. "Add toolbar with undo/redo, grid toggle, snap settings" (coder)
14. "Implement auto-save to localStorage" (coder)
```
---
## Summary
This design adds full interactivity to the house viewer while respecting the existing architecture:
- **No framework rewrite** — vanilla JS modules that compose with HouseRenderer
- **No new dependencies** — everything built with Three.js and browser APIs
- **Incremental delivery** — each sprint produces a usable improvement
- **Clean separation** — interaction, themes, export, and UI are independent modules
- **Undo/redo from day 1** — critical for any editor
- **14 implementation tasks**, roughly 5 sprints

352
RESEARCH-ikea-data.md Normal file
View 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
View File

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

213
bun.lock Normal file
View 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
View 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
View 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
View 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
View 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
}
]
}
}
}
]
}
]
}

View 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"
}
]
}
]
}

View 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

View 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"
}
]
}
]
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
screenshot-phase2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

361
scripts/import-ikea-hf.js Normal file
View 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
View 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
View File

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

754
src/floorplan-import.js Normal file
View 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
View 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;
}
}

View File

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

474
src/interaction.js Normal file
View File

@@ -0,0 +1,474 @@
import * as THREE from 'three';
/**
* InteractionManager — mode system, selection, keyboard shortcuts.
*
* Modes: view (default) | select | move | rotate | place
* Receives HouseRenderer + DesignState, hooks into renderer events.
*/
export class InteractionManager {
constructor(renderer, state) {
this.renderer = renderer;
this.state = state;
this.mode = 'view';
this.selectedObject = null;
this.selectedRoomId = null;
this.selectedIndex = -1;
this._outlineMaterial = new THREE.MeshBasicMaterial({
color: 0x00e5ff,
wireframe: true,
transparent: true,
opacity: 0.6
});
this._outlineGroup = null;
// Drag state
this._isDragging = false;
this._dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Y=0 floor plane
this._dragOffset = new THREE.Vector3();
this._dragStartPos = null; // world position at drag start
this.snapEnabled = true;
this.snapSize = 0.25; // metres
this._roomBounds = new Map();
this._furniturePadding = 0.05; // small buffer from walls
this._listeners = new Set();
// Bind event handlers (keep references for dispose)
this._onFurnitureClick = this._onFurnitureClick.bind(this);
this._onRoomClick = this._onRoomClick.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onFloorChange = this._onFloorChange.bind(this);
this._onPointerDown = this._onPointerDown.bind(this);
this._onPointerMove = this._onPointerMove.bind(this);
this._onPointerUp = this._onPointerUp.bind(this);
const canvas = this.renderer.renderer.domElement;
this.renderer.container.addEventListener('furnitureclick', this._onFurnitureClick);
this.renderer.container.addEventListener('roomclick', this._onRoomClick);
this.renderer.container.addEventListener('floorchange', this._onFloorChange);
canvas.addEventListener('pointerdown', this._onPointerDown);
canvas.addEventListener('pointermove', this._onPointerMove);
canvas.addEventListener('pointerup', this._onPointerUp);
window.addEventListener('keydown', this._onKeyDown);
// Listen to DesignState changes to keep scene in sync
this._unsubState = this.state.onChange((type, detail) => this._onStateChange(type, detail));
// Build initial room bounds
this._buildRoomBounds();
}
// ---- Mode system ----
setMode(newMode) {
if (this.mode === newMode) return;
const oldMode = this.mode;
this.mode = newMode;
// Leaving select/move/rotate means clear selection
if (newMode === 'view') {
this.clearSelection();
}
this._emit('modechange', { oldMode, newMode });
}
// ---- Selection ----
select(meshGroup) {
if (this.selectedObject === meshGroup) return;
this.clearSelection();
this.selectedObject = meshGroup;
this.selectedRoomId = meshGroup.userData.roomId;
this.selectedIndex = meshGroup.userData.furnitureIndex;
this._addOutline(meshGroup);
if (this.mode === 'view') {
this.mode = 'select';
}
this._emit('select', {
roomId: this.selectedRoomId,
index: this.selectedIndex,
catalogId: meshGroup.userData.catalogId,
itemName: meshGroup.userData.itemName,
mesh: meshGroup
});
}
clearSelection() {
if (!this.selectedObject) return;
this._removeOutline();
const prev = {
roomId: this.selectedRoomId,
index: this.selectedIndex
};
this.selectedObject = null;
this.selectedRoomId = null;
this.selectedIndex = -1;
this._emit('deselect', prev);
}
// ---- Keyboard shortcuts ----
_onKeyDown(event) {
// Don't intercept when typing in an input
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
// Ctrl+Z / Cmd+Z — undo
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key === 'z') {
event.preventDefault();
this.state.undo();
return;
}
// Ctrl+Shift+Z / Cmd+Shift+Z — redo
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'Z') {
event.preventDefault();
this.state.redo();
return;
}
// Ctrl+Y — redo (alternative)
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault();
this.state.redo();
return;
}
// Escape — deselect / return to view mode
if (event.key === 'Escape') {
this.clearSelection();
this.setMode('view');
return;
}
// Following shortcuts require a selection
if (!this.selectedObject) return;
// Delete / Backspace — remove selected furniture
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
this._deleteSelected();
return;
}
// R — rotate 90° clockwise
if (event.key === 'r' || event.key === 'R') {
const delta = event.shiftKey ? 90 : -90;
const item = this.state.getFurniture(this.selectedRoomId, this.selectedIndex);
if (item) {
const newRotation = ((item.rotation || 0) + delta + 360) % 360;
this.state.rotateFurniture(this.selectedRoomId, this.selectedIndex, newRotation);
}
return;
}
}
// ---- Event handlers ----
_onFurnitureClick(event) {
// Don't handle click if we just finished a drag
if (this._wasDragging) {
this._wasDragging = false;
return;
}
const { mesh } = event.detail;
if (mesh) {
this.select(mesh);
}
}
_onRoomClick(_event) {
// Clicking a room (not furniture) clears furniture selection
if (this.selectedObject) {
this.clearSelection();
}
}
_onFloorChange(_event) {
// Floor switch invalidates selection (meshes are disposed)
this._isDragging = false;
this.selectedObject = null;
this.selectedRoomId = null;
this.selectedIndex = -1;
this._outlineGroup = null;
this._buildRoomBounds();
}
// ---- Drag handling ----
_getNDC(event) {
const rect = this.renderer.renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
}
_onPointerDown(event) {
if (event.button !== 0) return; // left click only
if (!this.selectedObject || this.selectedObject.userData.wallMounted) return;
// Raycast to check if we're clicking the selected furniture
const ndc = this._getNDC(event);
this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera);
const hits = this.renderer.raycaster.intersectObject(this.selectedObject, true);
if (hits.length === 0) return;
// Calculate drag offset so object doesn't jump to cursor
const intersection = new THREE.Vector3();
this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection);
if (!intersection) return;
this._dragOffset.copy(this.selectedObject.position).sub(intersection);
this._dragStartPos = this.selectedObject.position.clone();
this._isDragging = true;
this._wasDragging = false;
this.mode = 'move';
// Disable orbit controls during drag
this.renderer.setControlsEnabled(false);
}
_onPointerMove(event) {
if (!this._isDragging || !this.selectedObject) return;
const ndc = this._getNDC(event);
this.renderer.raycaster.setFromCamera(ndc, this.renderer.camera);
const intersection = new THREE.Vector3();
this.renderer.raycaster.ray.intersectPlane(this._dragPlane, intersection);
if (!intersection) return;
// Apply drag offset
intersection.add(this._dragOffset);
// Grid snapping (snap world position)
if (this.snapEnabled) {
intersection.x = Math.round(intersection.x / this.snapSize) * this.snapSize;
intersection.z = Math.round(intersection.z / this.snapSize) * this.snapSize;
}
// Room bounds constraint
const bounds = this._roomBounds.get(this.selectedRoomId);
if (bounds) {
const pad = this._furniturePadding;
intersection.x = clamp(intersection.x, bounds.minX + pad, bounds.maxX - pad);
intersection.z = clamp(intersection.z, bounds.minZ + pad, bounds.maxZ - pad);
}
// Keep original Y
intersection.y = this.selectedObject.position.y;
this.selectedObject.position.copy(intersection);
}
_onPointerUp(_event) {
if (!this._isDragging) return;
this._isDragging = false;
this.renderer.setControlsEnabled(true);
if (!this.selectedObject || !this._dragStartPos) {
this.mode = 'select';
return;
}
// Check if position actually changed
const moved = !this.selectedObject.position.equals(this._dragStartPos);
if (moved) {
this._wasDragging = true; // prevent click handler from re-selecting
// Convert world position back to room-local coords for state
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
const room = floor?.rooms.find(r => r.id === this.selectedRoomId);
if (room) {
const localX = this.selectedObject.position.x - room.position.x;
const localZ = this.selectedObject.position.z - room.position.y;
this.state.moveFurniture(this.selectedRoomId, this.selectedIndex, { x: localX, z: localZ });
}
}
this.mode = 'select';
this._dragStartPos = null;
}
// ---- Room bounds ----
_buildRoomBounds() {
this._roomBounds.clear();
if (!this.renderer.houseData) return;
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
if (!floor) return;
for (const room of floor.rooms) {
this._roomBounds.set(room.id, {
minX: room.position.x,
maxX: room.position.x + room.dimensions.width,
minZ: room.position.y,
maxZ: room.position.y + room.dimensions.length
});
}
}
_onStateChange(type, _detail) {
// On undo/redo/load, re-render the floor to reflect new state
if (type === 'undo' || type === 'redo' || type === 'design-load') {
this.renderer.designData = this.state.design;
const wasSelected = this.selectedRoomId;
const wasIndex = this.selectedIndex;
this._outlineGroup = null;
this.selectedObject = null;
this.renderer._clearFloor();
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
if (floor) {
for (const room of floor.rooms) {
this.renderer._renderRoom(room, floor.ceilingHeight);
}
}
this.renderer._placeFurnitureForFloor();
// Try to re-select the same item after re-render
if (wasSelected !== null && wasIndex >= 0) {
const mesh = this._findFurnitureMesh(wasSelected, wasIndex);
if (mesh) {
this.select(mesh);
} else {
this.selectedRoomId = null;
this.selectedIndex = -1;
this._emit('deselect', { roomId: wasSelected, index: wasIndex });
}
}
}
// On individual mutations, update the mesh in place
if (type === 'furniture-move' || type === 'furniture-rotate') {
this._syncMeshFromState(_detail.roomId, _detail.index);
}
if (type === 'furniture-add' || type === 'furniture-remove') {
// Re-render the floor to reflect added/removed furniture
if (type === 'furniture-remove') this.clearSelection();
this.renderer.designData = this.state.design;
this.renderer._clearFloor();
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
if (floor) {
for (const room of floor.rooms) {
this.renderer._renderRoom(room, floor.ceilingHeight);
}
}
this.renderer._placeFurnitureForFloor();
}
}
// ---- Outline ----
_addOutline(meshGroup) {
this._removeOutline();
const outline = new THREE.Group();
outline.userData._isOutline = true;
meshGroup.traverse(child => {
if (child.isMesh && child.geometry) {
const clone = new THREE.Mesh(child.geometry, this._outlineMaterial);
// Copy the child's local transform relative to the group
clone.position.copy(child.position);
clone.rotation.copy(child.rotation);
clone.scale.copy(child.scale).multiplyScalar(1.04);
outline.add(clone);
}
});
meshGroup.add(outline);
this._outlineGroup = outline;
}
_removeOutline() {
if (this._outlineGroup && this._outlineGroup.parent) {
this._outlineGroup.parent.remove(this._outlineGroup);
}
this._outlineGroup = null;
}
// ---- Helpers ----
_deleteSelected() {
if (!this.selectedObject || this.selectedRoomId === null || this.selectedIndex < 0) return;
const roomId = this.selectedRoomId;
const index = this.selectedIndex;
this.clearSelection();
this.state.removeFurniture(roomId, index);
}
_findFurnitureMesh(roomId, index) {
for (const mesh of this.renderer.furnitureMeshes.values()) {
if (mesh.userData.roomId === roomId && mesh.userData.furnitureIndex === index) {
return mesh;
}
}
return null;
}
_syncMeshFromState(roomId, index) {
const item = this.state.getFurniture(roomId, index);
const mesh = this._findFurnitureMesh(roomId, index);
if (!item || !mesh) return;
// Get room position offset
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
const room = floor?.rooms.find(r => r.id === roomId);
if (!room) return;
const rx = room.position.x + item.position.x;
const rz = room.position.y + item.position.z;
const ry = item.wallMounted ? (item.position.y ?? 0) : 0;
mesh.position.set(rx, ry, rz);
mesh.rotation.y = -(item.rotation * Math.PI) / 180;
}
// ---- Observer pattern ----
/**
* Listen for interaction events.
* Types: select, deselect, modechange
* Returns unsubscribe function.
*/
onChange(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
_emit(type, detail) {
for (const fn of this._listeners) {
try {
fn(type, detail);
} catch (err) {
console.error('InteractionManager listener error:', err);
}
}
}
// ---- Cleanup ----
dispose() {
const canvas = this.renderer.renderer.domElement;
this.renderer.container.removeEventListener('furnitureclick', this._onFurnitureClick);
this.renderer.container.removeEventListener('roomclick', this._onRoomClick);
this.renderer.container.removeEventListener('floorchange', this._onFloorChange);
canvas.removeEventListener('pointerdown', this._onPointerDown);
canvas.removeEventListener('pointermove', this._onPointerMove);
canvas.removeEventListener('pointerup', this._onPointerUp);
window.removeEventListener('keydown', this._onKeyDown);
this._unsubState();
this._removeOutline();
this._outlineMaterial.dispose();
this._listeners.clear();
}
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}

View File

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

191
src/state.js Normal file
View File

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

124
src/themes.js Normal file
View File

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

View 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
View 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
View 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
View 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);
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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'),
}
}
});