Files
house-design/src/index.html
m 4d4d5f947b Add JSON save/load and PNG screenshot export
- ExportManager with JSON export (toJSON + timestamp), import with
  file picker and validation, and high-res PNG screenshot capture
- Save/Load/Screenshot buttons in sidebar
- Ctrl+S keyboard shortcut for quick save
- Design load event updates info bar
2026-02-07 12:26:05 +01:00

399 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>House Design Viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
#viewer { width: 100vw; height: 100vh; }
#sidebar {
position: fixed;
top: 0;
right: 0;
width: 280px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
border-left: 1px solid #ddd;
padding: 16px;
overflow-y: auto;
z-index: 10;
}
#sidebar h2 { font-size: 16px; margin-bottom: 12px; color: #333; }
#sidebar h3 { font-size: 13px; margin: 12px 0 6px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
.floor-btn {
display: inline-block;
padding: 6px 14px;
margin: 2px 4px 2px 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 13px;
}
.floor-btn.active { background: #4a90d9; color: #fff; border-color: #4a90d9; }
.room-item {
padding: 8px 10px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.room-item:hover { background: #e8f0fe; }
.room-item.active { background: #4a90d9; color: #fff; }
.room-item .area { font-size: 11px; opacity: 0.7; }
.theme-btn {
display: inline-block;
padding: 4px 10px;
margin: 2px 3px 2px 0;
border: 2px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1.4;
}
.theme-btn.active { border-color: #4a90d9; }
.theme-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
vertical-align: middle;
margin-right: 4px;
border: 1px solid rgba(0,0,0,0.15);
}
#info {
position: fixed;
bottom: 16px;
left: 260px;
background: rgba(0,0,0,0.7);
color: #fff;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
z-index: 10;
}
/* Catalog panel (left sidebar) */
#catalog-panel {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
border-right: 1px solid #ddd;
z-index: 10;
display: flex;
flex-direction: column;
}
.catalog-panel h3 {
font-size: 13px;
padding: 12px 12px 0;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.catalog-search {
padding: 12px 12px 8px;
}
.catalog-search-input {
width: 100%;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.catalog-search-input:focus {
border-color: #4a90d9;
}
.catalog-categories {
padding: 0 12px 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.catalog-cat-btn {
padding: 3px 8px;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 11px;
}
.catalog-cat-btn.active {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.catalog-items {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
}
.catalog-item {
display: flex;
align-items: center;
padding: 8px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.catalog-item:hover {
background: #e8f0fe;
}
.catalog-item-swatch {
width: 32px;
height: 32px;
border-radius: 4px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.1);
}
.catalog-item-info {
flex: 1;
margin-left: 8px;
min-width: 0;
}
.catalog-item-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.catalog-item-dims {
font-size: 10px;
color: #888;
margin-top: 2px;
}
.catalog-item-add {
width: 24px;
height: 24px;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
color: #4a90d9;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.catalog-item-add:hover {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.catalog-empty {
text-align: center;
color: #999;
padding: 20px 0;
font-size: 12px;
}
.export-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #ddd;
}
.export-btn {
display: inline-block;
padding: 6px 12px;
margin: 3px 4px 3px 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.export-btn:hover { background: #f0f0f0; }
.export-btn:active { background: #e0e0e0; }
</style>
</head>
<body>
<div id="viewer"></div>
<div id="catalog-panel"></div>
<div id="sidebar">
<h2 id="house-name">Loading...</h2>
<h3>Floors</h3>
<div id="floor-buttons"></div>
<h3>Rooms</h3>
<div id="room-list"></div>
<h3>Theme</h3>
<div id="theme-buttons"></div>
<div class="export-section">
<h3>File</h3>
<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-screenshot">Screenshot</button>
</div>
</div>
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
<script type="module">
import { HouseRenderer } from './renderer.js';
import { DesignState } from './state.js';
import { InteractionManager } from './interaction.js';
import { ThemeManager } from './themes.js';
import { ExportManager } from './export.js';
const viewer = document.getElementById('viewer');
const houseRenderer = new HouseRenderer(viewer);
let selectedRoom = null;
let designState = null;
let interaction = null;
let themeManager = null;
let exportManager = null;
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
document.getElementById('house-name').textContent = house.name;
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
const design = await houseRenderer.loadDesign('../designs/sample-house-design.json');
// Initialize state and interaction manager
designState = new DesignState(design);
interaction = new InteractionManager(houseRenderer, designState);
interaction.onChange((type, detail) => {
if (type === 'select') {
document.getElementById('info').textContent =
`Selected: ${detail.itemName} — R to rotate, Delete to remove, Escape to deselect`;
} else if (type === 'deselect') {
document.getElementById('info').textContent =
'Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.';
}
});
themeManager = new ThemeManager(houseRenderer);
exportManager = new ExportManager(houseRenderer, designState);
buildFloorButtons();
buildRoomList();
buildThemeButtons();
wireExportButtons();
}).catch(err => {
document.getElementById('house-name').textContent = 'Error loading data';
document.getElementById('info').textContent = err.message;
});
function buildFloorButtons() {
const container = document.getElementById('floor-buttons');
container.innerHTML = '';
for (const floor of houseRenderer.getFloors()) {
const btn = document.createElement('button');
btn.className = 'floor-btn' + (floor.index === houseRenderer.currentFloor ? ' active' : '');
btn.textContent = floor.name;
btn.addEventListener('click', () => {
houseRenderer.showFloor(floor.index);
document.querySelectorAll('.floor-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
buildRoomList();
selectedRoom = null;
});
container.appendChild(btn);
}
}
function buildRoomList() {
const container = document.getElementById('room-list');
container.innerHTML = '';
for (const room of houseRenderer.getRooms()) {
const item = document.createElement('div');
item.className = 'room-item';
item.dataset.roomId = room.id;
item.innerHTML = `<span>${room.name}</span><span class="area">${room.area} m²</span>`;
item.addEventListener('click', () => selectRoom(room.id));
container.appendChild(item);
}
}
function selectRoom(roomId) {
selectedRoom = roomId;
houseRenderer.focusRoom(roomId);
document.querySelectorAll('.room-item').forEach(el => {
el.classList.toggle('active', el.dataset.roomId === roomId);
});
const room = houseRenderer.getRooms().find(r => r.id === roomId);
if (room) {
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area}`;
}
}
viewer.addEventListener('roomclick', (e) => {
selectRoom(e.detail.roomId);
});
function buildThemeButtons() {
const container = document.getElementById('theme-buttons');
container.innerHTML = '';
for (const theme of themeManager.getThemes()) {
const btn = document.createElement('button');
btn.className = 'theme-btn' + (theme.id === themeManager.currentTheme ? ' active' : '');
btn.innerHTML = `<span class="theme-swatch" style="background:${theme.swatch}"></span>${theme.name}`;
btn.addEventListener('click', () => {
themeManager.applyTheme(theme.id);
document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
buildRoomList(); // re-render room list since floor was rebuilt
});
container.appendChild(btn);
}
}
function wireExportButtons() {
document.getElementById('btn-save').addEventListener('click', () => {
exportManager.exportDesignJSON();
});
document.getElementById('btn-load').addEventListener('click', () => {
exportManager.importDesignJSON();
});
document.getElementById('btn-screenshot').addEventListener('click', () => {
exportManager.exportScreenshot();
});
// Ctrl+S / Cmd+S to save design
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (exportManager) {
exportManager.exportDesignJSON();
}
}
});
}
// Update info bar when a design is loaded from file
viewer.addEventListener('designloaded', (e) => {
document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`;
});
</script>
</body>
</html>