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