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:
124
src/export.js
Normal file
124
src/export.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/index.html
180
src/index.html
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user