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