From 4d4d5f947bc51762e68d6ece9fc73fff24203ebb Mon Sep 17 00:00:00 2001 From: m Date: Sat, 7 Feb 2026 12:26:05 +0100 Subject: [PATCH] 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 --- src/export.js | 124 ++++++++++++++++++++++++++++++++++ src/index.html | 180 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/export.js diff --git a/src/export.js b/src/export.js new file mode 100644 index 0000000..1de114b --- /dev/null +++ b/src/export.js @@ -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(); + } +} diff --git a/src/index.html b/src/index.html index ae04e49..5921281 100644 --- a/src/index.html +++ b/src/index.html @@ -77,7 +77,7 @@ #info { position: fixed; bottom: 16px; - left: 16px; + left: 260px; background: rgba(0,0,0,0.7); color: #fff; padding: 8px 14px; @@ -85,11 +85,152 @@ 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; }
+
+
Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.
@@ -115,6 +262,7 @@ 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); @@ -123,6 +271,7 @@ 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; @@ -144,9 +293,11 @@ }); 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; @@ -215,6 +366,33 @@ 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}`; + });