From d53686ed658209f866e8e693bd5d9b261157eddd Mon Sep 17 00:00:00 2001 From: m Date: Sat, 7 Feb 2026 16:38:14 +0100 Subject: [PATCH] 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 --- src/floorplan-import.js | 754 +++++++++++++++++++++++++++++++++ src/index.html | 22 + tests/floorplan-import.test.js | 292 +++++++++++++ 3 files changed, 1068 insertions(+) create mode 100644 src/floorplan-import.js create mode 100644 tests/floorplan-import.test.js diff --git a/src/floorplan-import.js b/src/floorplan-import.js new file mode 100644 index 0000000..52045d8 --- /dev/null +++ b/src/floorplan-import.js @@ -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 = ` + +
+

Import Floor Plan

+ +
+ +
Drop image here or click to browse
PNG, JPG, WebP
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ Analyzing floor plan... +
+
+ +
+ + +
+ +
+

Result Preview

+
+
+ +
+ +
+ +
+ + + + +
+
+
+ `; + + // 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": , "depth": }, + "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": , "y": }, + "dimensions": { "width": , "length": }, + "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 = `${room.nameEN || room.name}${w.toFixed(1)} x ${l.toFixed(1)}m`; + 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); + } + } +} diff --git a/src/index.html b/src/index.html index bf15ba8..c5001ed 100644 --- a/src/index.html +++ b/src/index.html @@ -587,6 +587,7 @@ +
@@ -609,6 +610,7 @@ 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 houseRenderer = new HouseRenderer(viewer); @@ -620,6 +622,7 @@ let exportManager = null; let catalogPanel = null; let houseEditor = null; + let floorplanImporter = null; houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => { document.getElementById('house-name').textContent = house.name; @@ -659,6 +662,16 @@ 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(); @@ -746,6 +759,9 @@ 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) => { @@ -762,6 +778,12 @@ 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' : ''})`; + }); diff --git a/tests/floorplan-import.test.js b/tests/floorplan-import.test.js new file mode 100644 index 0000000..a6eee09 --- /dev/null +++ b/tests/floorplan-import.test.js @@ -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)'); + }); + }); +});