Compare commits
2 Commits
53999728c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e498818a7 | ||
|
|
d53686ed65 |
504
designs/floorplan-import-design.md
Normal file
504
designs/floorplan-import-design.md
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
# Floor Plan Image Recognition — Feature Design
|
||||||
|
|
||||||
|
**Task:** t-c2921
|
||||||
|
**Author:** inventor
|
||||||
|
**Status:** Design proposal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users want to import an existing floor plan image (architect drawing, realtor photo, hand sketch) and have it automatically converted into the project's house JSON format so they can immediately view it in 3D, furnish rooms, and iterate on the design.
|
||||||
|
|
||||||
|
## Approach: LLM Vision API
|
||||||
|
|
||||||
|
After evaluating four approaches, the recommended solution uses **multimodal LLM vision** (Claude or OpenAI) to analyze floor plan images and output structured house JSON.
|
||||||
|
|
||||||
|
### Why LLM Vision over alternatives
|
||||||
|
|
||||||
|
| Approach | Pros | Cons | Verdict |
|
||||||
|
|----------|------|------|---------|
|
||||||
|
| **Classical CV** (OpenCV.js edge detection) | No API needed, offline | Can't identify room types, fails on varied styles, needs heavy heuristics | Too fragile |
|
||||||
|
| **LLM Vision** (Claude/GPT-4V) | Understands semantics, handles variety, outputs JSON directly | Needs API key + network | **Best fit** |
|
||||||
|
| **Dedicated ML** (YOLO/CubiCasa models) | High accuracy for specific styles | Heavy model files (~100MB+), complex setup, breaks vanilla JS philosophy | Too heavy |
|
||||||
|
| **Hybrid CV + LLM** | Best of both worlds | More complexity for marginal gain | Overengineered for v1 |
|
||||||
|
|
||||||
|
**Key reasons:**
|
||||||
|
1. Project is vanilla JS with no build system — adding ML runtimes is architecturally wrong
|
||||||
|
2. Floor plans are inherently semantic — you need to know "this is a kitchen" not just "this is a rectangle"
|
||||||
|
3. LLMs can output the exact house JSON format in a single call
|
||||||
|
4. LLMs handle architectural drawings, realtor floor plans, and hand sketches equally well
|
||||||
|
5. Standard door widths (~0.9m) give LLMs reliable dimensional anchors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New module: `src/floorplan-import.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
FloorplanImporter
|
||||||
|
├── constructor(renderer, options)
|
||||||
|
├── open() // Shows the import modal
|
||||||
|
├── _buildModal() // Creates DOM for the modal overlay
|
||||||
|
├── _handleImageUpload(file) // Processes uploaded image
|
||||||
|
├── _preprocessImage(imageData) // Canvas preprocessing (contrast, resize)
|
||||||
|
├── _analyzeWithLLM(base64Image) // Sends to vision API, gets house JSON
|
||||||
|
├── _buildPrompt() // Constructs the system+user prompt
|
||||||
|
├── _validateHouseJSON(json) // Validates output matches schema
|
||||||
|
├── _applyToRenderer(houseData) // Loads result into the 3D viewer
|
||||||
|
├── _showPreview(houseData) // Shows result for user review
|
||||||
|
└── close() // Closes modal, cleans up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration point: `src/index.html`
|
||||||
|
|
||||||
|
New button in the sidebar File section:
|
||||||
|
```html
|
||||||
|
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired in the `wireExportButtons()` function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Import Floor Plan" in sidebar
|
||||||
|
│
|
||||||
|
2. Modal overlay appears with:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Import Floor Plan │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Drop image here or │ │
|
||||||
|
│ │ click to browse │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ PNG, JPG, WebP │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Building name: [___________] │
|
||||||
|
│ Floors shown: [1 ▼] │
|
||||||
|
│ │
|
||||||
|
│ API: [Claude ▼] Key: [••••••] │
|
||||||
|
│ │
|
||||||
|
│ [Analyze Floor Plan] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
3. Image uploaded → shown in preview area
|
||||||
|
│
|
||||||
|
4. User clicks "Analyze" → spinner + progress text
|
||||||
|
│
|
||||||
|
5. LLM returns house JSON
|
||||||
|
│
|
||||||
|
6. Preview mode shows:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Result Preview │
|
||||||
|
│ │
|
||||||
|
│ Found: 6 rooms, 8 doors, │
|
||||||
|
│ 12 windows │
|
||||||
|
│ │
|
||||||
|
│ Rooms: │
|
||||||
|
│ ☑ Living Room 4.5 × 5.5m │
|
||||||
|
│ ☑ Kitchen 4.0 × 3.5m │
|
||||||
|
│ ☑ Hallway 2.0 × 9.0m │
|
||||||
|
│ ☑ Bathroom 2.5 × 3.0m │
|
||||||
|
│ ☑ Bedroom 4.5 × 4.0m │
|
||||||
|
│ ☑ Office 3.5 × 3.0m │
|
||||||
|
│ │
|
||||||
|
│ [Accept & Load] [Edit JSON] │
|
||||||
|
│ [Re-analyze] [Cancel] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
7a. "Accept" → loads house JSON into renderer,
|
||||||
|
rebuilds floor buttons, room list, 3D view
|
||||||
|
│
|
||||||
|
7b. "Edit JSON" → opens raw JSON in textarea
|
||||||
|
for manual corrections before loading
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Prompt (Core of the Feature)
|
||||||
|
|
||||||
|
The prompt engineering is the most critical part. It must produce valid house JSON from any floor plan style.
|
||||||
|
|
||||||
|
### System prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a floor plan analyzer. Given an image of a floor plan or floor layout,
|
||||||
|
extract the room structure and output valid JSON matching the exact schema below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All dimensions in meters. Use standard architectural conventions if no scale bar
|
||||||
|
is visible (standard interior door = 0.9m wide, entry door = 1.0-1.1m)
|
||||||
|
- Rooms are axis-aligned rectangles positioned on a coordinate grid
|
||||||
|
- Position {x, y} is the room's bottom-left corner (x = west-east, y = south-north)
|
||||||
|
- Each room has walls on 4 cardinal directions (north, south, east, west)
|
||||||
|
- Walls are "exterior" if they face outside the building, "interior" otherwise
|
||||||
|
- Doors have: id, type (entry|interior|patio|open), position (meters from wall start),
|
||||||
|
width, height, connectsTo (adjacent room id or "exterior")
|
||||||
|
- Windows have: id, type (casement|fixed), position, width, height, sillHeight
|
||||||
|
- Generate unique IDs: "{floorId}-{roomSlug}" for rooms, "{roomId}-d{n}" for doors,
|
||||||
|
"{roomId}-w{n}" for windows
|
||||||
|
- Room types: living, kitchen, dining, bedroom, bathroom, hallway, office, utility,
|
||||||
|
storage, laundry, garage
|
||||||
|
- Flooring: "tile" for kitchen/bathroom/utility/hallway, "hardwood" for others
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences, no explanation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt template
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze this floor plan image. The building is named "{name}".
|
||||||
|
{scaleHint ? "Scale reference: " + scaleHint : "Estimate dimensions from standard door widths."}
|
||||||
|
This image shows {floorCount} floor(s).
|
||||||
|
|
||||||
|
Output the house JSON with this structure:
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": <number>, "depth": <number> },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "gable"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-room-slug",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"type": "living|kitchen|...",
|
||||||
|
"position": { "x": <meters>, "y": <meters> },
|
||||||
|
"dimensions": { "width": <meters>, "length": <meters> },
|
||||||
|
"flooring": "tile|hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": { "type": "exterior|interior", "doors": [...], "windows": [...] },
|
||||||
|
"north": { ... },
|
||||||
|
"east": { ... },
|
||||||
|
"west": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Multi-provider support
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude (Anthropic)',
|
||||||
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.content[0].text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (GPT-4o)',
|
||||||
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64Image}` } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API key management
|
||||||
|
|
||||||
|
- Stored in `localStorage` under `floorplan-api-key-{provider}`
|
||||||
|
- Entered once per session via the import modal
|
||||||
|
- Never sent to any server except the chosen API provider
|
||||||
|
- Key input field uses `type="password"` and shows masked value
|
||||||
|
- "Clear key" button to remove from localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Preprocessing
|
||||||
|
|
||||||
|
Before sending to the LLM, apply lightweight canvas preprocessing:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_preprocessImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
// Resize if larger than 2048px on any side (API limits + cost reduction)
|
||||||
|
const maxDim = 2048;
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw and optionally enhance contrast for faded plans
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert to base64 (JPEG for photos, PNG for drawings)
|
||||||
|
const mediaType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = mediaType === 'image/jpeg' ? 0.9 : undefined;
|
||||||
|
const base64 = canvas.toDataURL(mediaType, quality).split(',')[1];
|
||||||
|
|
||||||
|
resolve({ base64, mediaType, width, height });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After receiving LLM output, validate before loading:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_validateHouseJSON(data) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!data.name) errors.push('Missing building name');
|
||||||
|
if (!data.building?.footprint) errors.push('Missing building footprint');
|
||||||
|
if (!data.floors?.length) errors.push('No floors found');
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.rooms?.length) {
|
||||||
|
errors.push(`Floor "${floor.name}" has no rooms`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
if (!room.id) errors.push(`Room missing id`);
|
||||||
|
if (!room.position) errors.push(`Room "${room.id}" missing position`);
|
||||||
|
if (!room.dimensions) errors.push(`Room "${room.id}" missing dimensions`);
|
||||||
|
if (!room.walls) errors.push(`Room "${room.id}" missing walls`);
|
||||||
|
|
||||||
|
// Validate wall references
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
const wall = room.walls?.[dir];
|
||||||
|
if (!wall) errors.push(`Room "${room.id}" missing ${dir} wall`);
|
||||||
|
if (wall && !['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
errors.push(`Room "${room.id}" ${dir} wall has invalid type "${wall.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-repair
|
||||||
|
|
||||||
|
Common LLM output issues and fixes:
|
||||||
|
- Missing wall entries → default to `{ "type": "interior" }`
|
||||||
|
- String numbers → parse to float
|
||||||
|
- Missing IDs → auto-generate from room name
|
||||||
|
- Missing flooring → infer from room type
|
||||||
|
- Rooms without walls object → generate empty walls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scale Detection Strategy
|
||||||
|
|
||||||
|
Dimensions are the hardest part. The LLM handles this through:
|
||||||
|
|
||||||
|
1. **Standard references** — Interior doors are ~0.9m, entry doors ~1.0-1.1m, windows ~1.2m. The LLM uses these as implicit scale anchors.
|
||||||
|
|
||||||
|
2. **User-provided scale** — Optional input: "The living room is approximately 5m wide" or "Scale: 1cm = 0.5m". Passed as a hint in the prompt.
|
||||||
|
|
||||||
|
3. **Scale bar detection** — If the floor plan has a scale bar, the LLM reads it directly.
|
||||||
|
|
||||||
|
4. **Post-import adjustment** — After loading, user can use the existing House Editor to manually adjust any room dimensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading into Renderer
|
||||||
|
|
||||||
|
After validation, the house JSON replaces the current house:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_applyToRenderer(houseData) {
|
||||||
|
// Replace house data in renderer
|
||||||
|
this.renderer.houseData = houseData;
|
||||||
|
this.renderer.currentFloor = 0;
|
||||||
|
|
||||||
|
// Clear and re-render
|
||||||
|
this.renderer._clearFloor();
|
||||||
|
const floor = houseData.floors[0];
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this.renderer._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for UI to rebuild floor buttons, room list, etc.
|
||||||
|
this.renderer.container.dispatchEvent(new CustomEvent('houseloaded', {
|
||||||
|
detail: { name: houseData.name, floors: houseData.floors.length }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `index.html` would listen for `houseloaded` and rebuild:
|
||||||
|
- Floor buttons
|
||||||
|
- Room list
|
||||||
|
- House editor state
|
||||||
|
- Reset camera position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
floorplan-import.js # New module — FloorplanImporter class
|
||||||
|
index.html # Modified — add button + wire up + houseloaded event
|
||||||
|
```
|
||||||
|
|
||||||
|
No new dependencies. No build changes. Pure vanilla JS using:
|
||||||
|
- `fetch()` for API calls
|
||||||
|
- `Canvas API` for image preprocessing
|
||||||
|
- `FileReader` / `Blob` for image handling
|
||||||
|
- `localStorage` for API key persistence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS (inline in modal, consistent with project style)
|
||||||
|
|
||||||
|
The modal uses the same design language as existing UI:
|
||||||
|
- `rgba(255, 255, 255, 0.95)` backgrounds
|
||||||
|
- `#4a90d9` accent color
|
||||||
|
- `-apple-system, BlinkMacSystemFont` font stack
|
||||||
|
- `border-radius: 4-6px` on elements
|
||||||
|
- Same button styles as `.export-btn`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Handling |
|
||||||
|
|------|----------|
|
||||||
|
| Multi-floor image (side by side) | Prompt asks LLM to detect multiple floors |
|
||||||
|
| Hand-drawn sketch | LLM handles well; dimensions will be approximate |
|
||||||
|
| Photo of printed plan | Canvas preprocessing helps; LLM reads spatial layout |
|
||||||
|
| Non-English labels | LLM translates; output uses both original + English names |
|
||||||
|
| Very large image (>10MB) | Canvas resizes to max 2048px before base64 encoding |
|
||||||
|
| LLM returns invalid JSON | Parse error → show raw text → let user "Edit JSON" |
|
||||||
|
| LLM returns partial data | Validation finds gaps → auto-repair what's possible, flag rest |
|
||||||
|
| API rate limit | Show error, suggest retry after delay |
|
||||||
|
| No API key | Modal won't allow "Analyze" without key entered |
|
||||||
|
| Curved walls / non-rectangular rooms | Approximate as rectangles (project constraint) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Estimate
|
||||||
|
|
||||||
|
Per floor plan analysis:
|
||||||
|
- **Claude Sonnet**: ~$0.01-0.03 per image (vision + ~2K output tokens)
|
||||||
|
- **GPT-4o**: ~$0.01-0.05 per image
|
||||||
|
- Negligible for individual use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Recommendations
|
||||||
|
|
||||||
|
### For the coder:
|
||||||
|
|
||||||
|
1. **Start with the prompt** — get `_buildPrompt()` right first, test with various floor plan images manually via the API before building the UI.
|
||||||
|
|
||||||
|
2. **Build the modal** — follow the existing modal-free overlay pattern (the project uses no modal library; use a simple overlay div).
|
||||||
|
|
||||||
|
3. **Wire up the API** — start with Claude support, add OpenAI second. The provider abstraction makes this easy.
|
||||||
|
|
||||||
|
4. **Add validation + auto-repair** — defensive parsing of LLM output is essential.
|
||||||
|
|
||||||
|
5. **Handle the `houseloaded` event** in index.html — rebuild all sidebar UI.
|
||||||
|
|
||||||
|
6. **Test with varied floor plans:**
|
||||||
|
- Clean architectural drawing (should work great)
|
||||||
|
- Realtor-style colored floor plan (should work well)
|
||||||
|
- Hand sketch on paper (should work, approximate dimensions)
|
||||||
|
- Photo of a floor plan on screen (should work with preprocessing)
|
||||||
|
|
||||||
|
### Testing approach:
|
||||||
|
- Save example floor plan images in `data/test-floorplans/`
|
||||||
|
- Compare LLM output against manually created house JSON
|
||||||
|
- Check that output loads in 3D viewer without errors
|
||||||
|
- Verify rooms don't overlap and walls connect properly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (out of scope for v1)
|
||||||
|
|
||||||
|
- **Local model support** — Run a local vision model (via Ollama) for offline use
|
||||||
|
- **PDF import** — Extract floor plan pages from architectural PDFs
|
||||||
|
- **Multi-floor stitching** — Upload separate images per floor, align them
|
||||||
|
- **Overlay comparison** — Show original image as ground texture under 3D rooms
|
||||||
|
- **Iterative refinement** — "The kitchen should be wider" → re-prompt with corrections
|
||||||
|
- **Scale calibration tool** — Click two points on image, enter real distance
|
||||||
754
src/floorplan-import.js
Normal file
754
src/floorplan-import.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
292
tests/floorplan-import.test.js
Normal file
292
tests/floorplan-import.test.js
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user