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
This commit is contained in:
m
2026-02-07 16:38:14 +01:00
parent 53999728c4
commit d53686ed65
3 changed files with 1068 additions and 0 deletions

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

View File

@@ -587,6 +587,7 @@
<button class="export-btn" id="btn-save">Save JSON</button> <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-load">Load JSON</button>
<button class="export-btn" id="btn-screenshot">Screenshot</button> <button class="export-btn" id="btn-screenshot">Screenshot</button>
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
</div> </div>
<div id="house-editor"></div> <div id="house-editor"></div>
</div> </div>
@@ -609,6 +610,7 @@
import { ExportManager } from './export.js'; import { ExportManager } from './export.js';
import { CatalogPanel } from './catalog.js'; import { CatalogPanel } from './catalog.js';
import { HouseEditor } from './house-editor.js'; import { HouseEditor } from './house-editor.js';
import { FloorplanImporter } from './floorplan-import.js';
const viewer = document.getElementById('viewer'); const viewer = document.getElementById('viewer');
const houseRenderer = new HouseRenderer(viewer); const houseRenderer = new HouseRenderer(viewer);
@@ -620,6 +622,7 @@
let exportManager = null; let exportManager = null;
let catalogPanel = null; let catalogPanel = null;
let houseEditor = null; let houseEditor = null;
let floorplanImporter = null;
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => { houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
document.getElementById('house-name').textContent = house.name; document.getElementById('house-name').textContent = house.name;
@@ -659,6 +662,16 @@
buildRoomList(); buildRoomList();
} }
}); });
floorplanImporter = new FloorplanImporter(houseRenderer, {
onHouseLoaded: (houseData) => {
document.getElementById('house-name').textContent = houseData.name;
buildFloorButtons();
buildRoomList();
selectedRoom = null;
if (catalogPanel) catalogPanel.setSelectedRoom(null);
if (houseEditor) houseEditor.setSelectedRoom(null);
}
});
buildFloorButtons(); buildFloorButtons();
buildRoomList(); buildRoomList();
buildThemeButtons(); buildThemeButtons();
@@ -746,6 +759,9 @@
document.getElementById('btn-screenshot').addEventListener('click', () => { document.getElementById('btn-screenshot').addEventListener('click', () => {
exportManager.exportScreenshot(); exportManager.exportScreenshot();
}); });
document.getElementById('btn-import-floorplan').addEventListener('click', () => {
if (floorplanImporter) floorplanImporter.open();
});
// Ctrl+S / Cmd+S to save design // Ctrl+S / Cmd+S to save design
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
@@ -762,6 +778,12 @@
viewer.addEventListener('designloaded', (e) => { viewer.addEventListener('designloaded', (e) => {
document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`; document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`;
}); });
// Update UI when a floor plan is imported
viewer.addEventListener('houseloaded', (e) => {
document.getElementById('info').textContent =
`Imported floor plan: ${e.detail.name} (${e.detail.floors} floor${e.detail.floors > 1 ? 's' : ''})`;
});
</script> </script>
</body> </body>
</html> </html>

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