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
This commit is contained in:
m
2026-02-07 12:26:05 +01:00
parent 08248c6cad
commit 4d4d5f947b
2 changed files with 303 additions and 1 deletions

124
src/export.js Normal file
View File

@@ -0,0 +1,124 @@
import * as THREE from 'three';
/**
* ExportManager — JSON save/load and PNG screenshot export.
*
* Receives HouseRenderer + DesignState, provides download/upload
* functionality for design files and viewport screenshots.
*/
export class ExportManager {
constructor(renderer, state) {
this.renderer = renderer;
this.state = state;
}
/** Export current design state as a downloadable JSON file. */
exportDesignJSON() {
const data = {
...this.state.toJSON(),
exportedAt: new Date().toISOString()
};
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
this._downloadBlob(blob, `${data.name || 'design'}.json`);
}
/** Open a file picker and load a design JSON file. */
importDesignJSON() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
this._loadDesignFile(file);
});
input.click();
}
/** Load a design from a File object. */
async _loadDesignFile(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
// Basic validation: must have rooms array
if (!data.rooms || !Array.isArray(data.rooms)) {
throw new Error('Invalid design file: missing rooms array');
}
this.state.loadDesign(data);
this.renderer.designData = this.state.design;
// Re-render current floor with new design
this.renderer._clearFloor();
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
if (floor) {
for (const room of floor.rooms) {
this.renderer._renderRoom(room, floor.ceilingHeight);
}
}
this.renderer._placeFurnitureForFloor();
this.renderer.container.dispatchEvent(new CustomEvent('designloaded', {
detail: { name: data.name || file.name }
}));
} catch (err) {
console.error('Failed to load design:', err);
this.renderer.container.dispatchEvent(new CustomEvent('loaderror', {
detail: { source: 'importDesign', error: err.message }
}));
}
}
/**
* Capture the current 3D view as a PNG and download it.
* Temporarily resizes the renderer for high-res output.
*/
exportScreenshot(width = 1920, height = 1080) {
const r = this.renderer.renderer;
// Save current size
const prevSize = new THREE.Vector2();
r.getSize(prevSize);
const prevPixelRatio = r.getPixelRatio();
// Resize for capture
r.setPixelRatio(1);
r.setSize(width, height);
this.renderer.camera.aspect = width / height;
this.renderer.camera.updateProjectionMatrix();
// Render one frame
r.render(this.renderer.scene, this.renderer.camera);
// Grab the image
const dataURL = r.domElement.toDataURL('image/png');
// Restore original size
r.setPixelRatio(prevPixelRatio);
r.setSize(prevSize.x, prevSize.y);
this.renderer.camera.aspect = prevSize.x / prevSize.y;
this.renderer.camera.updateProjectionMatrix();
this._downloadDataURL(dataURL, 'house-design.png');
}
// ---- Internal helpers ----
_downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
_downloadDataURL(dataURL, filename) {
const a = document.createElement('a');
a.href = dataURL;
a.download = filename;
a.click();
}
}

View File

@@ -77,7 +77,7 @@
#info { #info {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
left: 16px; left: 260px;
background: rgba(0,0,0,0.7); background: rgba(0,0,0,0.7);
color: #fff; color: #fff;
padding: 8px 14px; padding: 8px 14px;
@@ -85,11 +85,152 @@
font-size: 13px; font-size: 13px;
z-index: 10; 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> </style>
</head> </head>
<body> <body>
<div id="viewer"></div> <div id="viewer"></div>
<div id="catalog-panel"></div>
<div id="sidebar"> <div id="sidebar">
<h2 id="house-name">Loading...</h2> <h2 id="house-name">Loading...</h2>
<h3>Floors</h3> <h3>Floors</h3>
@@ -98,6 +239,12 @@
<div id="room-list"></div> <div id="room-list"></div>
<h3>Theme</h3> <h3>Theme</h3>
<div id="theme-buttons"></div> <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>
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div> <div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
@@ -115,6 +262,7 @@
import { DesignState } from './state.js'; import { DesignState } from './state.js';
import { InteractionManager } from './interaction.js'; import { InteractionManager } from './interaction.js';
import { ThemeManager } from './themes.js'; import { ThemeManager } from './themes.js';
import { ExportManager } from './export.js';
const viewer = document.getElementById('viewer'); const viewer = document.getElementById('viewer');
const houseRenderer = new HouseRenderer(viewer); const houseRenderer = new HouseRenderer(viewer);
@@ -123,6 +271,7 @@
let designState = null; let designState = null;
let interaction = null; let interaction = null;
let themeManager = null; let themeManager = null;
let exportManager = 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;
@@ -144,9 +293,11 @@
}); });
themeManager = new ThemeManager(houseRenderer); themeManager = new ThemeManager(houseRenderer);
exportManager = new ExportManager(houseRenderer, designState);
buildFloorButtons(); buildFloorButtons();
buildRoomList(); buildRoomList();
buildThemeButtons(); buildThemeButtons();
wireExportButtons();
}).catch(err => { }).catch(err => {
document.getElementById('house-name').textContent = 'Error loading data'; document.getElementById('house-name').textContent = 'Error loading data';
document.getElementById('info').textContent = err.message; document.getElementById('info').textContent = err.message;
@@ -215,6 +366,33 @@
container.appendChild(btn); 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> </script>
</body> </body>
</html> </html>