Skip to content

Commit 57b5b28

Browse files
committed
feat: save/load settings with export/import and auto-save
Add preset export/import via .bumpmesh files (ZIP containing settings, optional model STL, optional custom texture PNG). Two icon buttons in the header open an export dialog with checkboxes and a file picker. - Export dialog lets users choose what to include (settings always, model and custom texture optionally) - Import via file picker or drag & drop of .bumpmesh files - Auto-save to localStorage on every settings change, restore on reload - Uses fflate (already bundled) for ZIP creation/extraction - Full i18n support (EN/DE) for all new UI elements
1 parent d99c97f commit 57b5b28

4 files changed

Lines changed: 343 additions & 1 deletion

File tree

index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@
4444
<span>BumpMesh <small style="opacity:.6;font-weight:400">by CNC Kitchen</small></span>
4545
</div>
4646
<div class="header-actions">
47+
<div class="preset-io">
48+
<button id="export-settings-btn" class="icon-btn" data-i18n-title="header.exportSettings" title="Export settings">
49+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
50+
</button>
51+
<input type="file" id="import-settings-input" accept=".bumpmesh,.json" hidden />
52+
<label for="import-settings-input" class="icon-btn" data-i18n-title="header.importSettings" title="Import settings" role="button" tabindex="0">
53+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
54+
</label>
55+
<div id="export-dialog" class="export-dialog hidden">
56+
<label><input type="checkbox" id="export-settings-chk" checked disabled /> <span data-i18n="header.exportSettingsLabel">Settings</span></label>
57+
<label><input type="checkbox" id="export-model-chk" /> <span data-i18n="header.exportModelLabel">Model (STL)</span></label>
58+
<label id="export-texture-row" class="hidden"><input type="checkbox" id="export-texture-chk" /> <span data-i18n="header.exportTextureLabel">Custom Texture</span></label>
59+
<button id="export-go-btn" class="export-go-btn" data-i18n="header.exportGo">Export</button>
60+
</div>
61+
</div>
4762
<div class="lang-seg">
4863
<button class="lang-btn active" data-lang-code="en">EN</button>
4964
<button class="lang-btn" data-lang-code="de">DE</button>

js/i18n.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ export const TRANSLATIONS = {
99
'theme.toggleTitle': 'Toggle light / dark mode',
1010
'theme.toggleAriaLabel': 'Toggle light/dark mode',
1111

12+
// Export / Import settings
13+
'header.exportSettings': 'Export settings',
14+
'header.importSettings': 'Import settings',
15+
'header.exportSettingsLabel':'Settings',
16+
'header.exportModelLabel': 'Model (STL)',
17+
'header.exportTextureLabel': 'Custom Texture',
18+
'header.exportGo': 'Export',
19+
'alerts.importSuccess': 'Settings imported successfully',
20+
'alerts.importFailed': 'Failed to import settings: {msg}',
21+
'alerts.importNoFile': 'No .bumpmesh file selected',
22+
1223
// Drop zone
1324
'dropHint.text': 'Drop an <strong>.stl</strong>, <strong>.obj</strong> or <strong>.3mf</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label>',
1425

@@ -194,6 +205,17 @@ export const TRANSLATIONS = {
194205
'theme.toggleTitle': 'Hell/Dunkel-Modus wechseln',
195206
'theme.toggleAriaLabel': 'Hell/Dunkel-Modus wechseln',
196207

208+
// Export / Import Einstellungen
209+
'header.exportSettings': 'Einstellungen exportieren',
210+
'header.importSettings': 'Einstellungen importieren',
211+
'header.exportSettingsLabel':'Einstellungen',
212+
'header.exportModelLabel': 'Modell (STL)',
213+
'header.exportTextureLabel': 'Eigene Textur',
214+
'header.exportGo': 'Exportieren',
215+
'alerts.importSuccess': 'Einstellungen erfolgreich importiert',
216+
'alerts.importFailed': 'Import fehlgeschlagen: {msg}',
217+
'alerts.importNoFile': 'Keine .bumpmesh-Datei ausgewählt',
218+
197219
// Drop zone
198220
'dropHint.text': '<strong>.stl</strong>-, <strong>.obj</strong>- oder <strong>.3mf</strong>-Datei hier ablegen<br/>oder <label for="stl-file-input" class="link-label">zum Durchsuchen klicken</label>',
199221

js/main.js

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { exportSTL } from './exporter.js';
1212
import { buildAdjacency, bucketFill,
1313
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
1414
import { t, initLang, setLang, getLang, applyTranslations } from './i18n.js';
15+
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
1516

1617
// ── State ─────────────────────────────────────────────────────────────────────
1718

@@ -370,6 +371,8 @@ function wireEvents() {
370371
dropZone.addEventListener('drop', (e) => {
371372
e.preventDefault();
372373
dropZone.classList.remove('drag-over');
374+
const bmFile = [...e.dataTransfer.files].find(f => /\.bumpmesh$/i.test(f.name));
375+
if (bmFile) { importBumpmesh(bmFile); return; }
373376
const file = [...e.dataTransfer.files].find(f => /\.(stl|obj|3mf)$/i.test(f.name));
374377
if (file) handleModelFile(file);
375378
});
@@ -1676,6 +1679,8 @@ function updatePreview() {
16761679
}
16771680

16781681
exportBtn.disabled = false;
1682+
1683+
_saveToLocalStorage();
16791684
}
16801685

16811686
// ── Displacement preview ──────────────────────────────────────────────────────
@@ -2260,3 +2265,237 @@ function runAsync(fn) {
22602265
function yieldFrame() {
22612266
return new Promise(r => requestAnimationFrame(r));
22622267
}
2268+
2269+
// ── Export/Import Settings (.bumpmesh) ───────────────────────────────────────
2270+
2271+
const exportSettingsBtn = document.getElementById('export-settings-btn');
2272+
const exportDialog = document.getElementById('export-dialog');
2273+
const exportGoBtn = document.getElementById('export-go-btn');
2274+
const exportModelChk = document.getElementById('export-model-chk');
2275+
const exportTextureChk = document.getElementById('export-texture-chk');
2276+
const exportTextureRow = document.getElementById('export-texture-row');
2277+
const importInput = document.getElementById('import-settings-input');
2278+
2279+
// Export dialog toggle
2280+
exportSettingsBtn.addEventListener('click', () => {
2281+
exportDialog.classList.toggle('hidden');
2282+
// Show texture checkbox only if custom texture is loaded
2283+
exportTextureRow.classList.toggle('hidden', !activeMapEntry || !activeMapEntry.isCustom);
2284+
// Enable model checkbox only if a model is loaded
2285+
exportModelChk.disabled = !currentGeometry;
2286+
});
2287+
2288+
// Close dialog when clicking outside
2289+
document.addEventListener('click', (e) => {
2290+
if (!exportDialog.contains(e.target) && e.target !== exportSettingsBtn && !exportSettingsBtn.contains(e.target)) {
2291+
exportDialog.classList.add('hidden');
2292+
}
2293+
});
2294+
2295+
// Export: build .bumpmesh ZIP and download
2296+
exportGoBtn.addEventListener('click', async () => {
2297+
exportDialog.classList.add('hidden');
2298+
2299+
const includeModel = exportModelChk.checked && currentGeometry;
2300+
const includeTexture = exportTextureChk.checked && activeMapEntry && activeMapEntry.isCustom;
2301+
2302+
const data = {
2303+
version: 1,
2304+
texture: activeMapEntry ? activeMapEntry.name : null,
2305+
settings: { ...settings },
2306+
};
2307+
2308+
const zipFiles = {};
2309+
2310+
// Settings JSON (always included)
2311+
zipFiles['settings.json'] = strToU8(JSON.stringify(data, null, 2));
2312+
2313+
// Model as binary STL
2314+
if (includeModel) {
2315+
const posArr = currentGeometry.attributes.position.array;
2316+
const norArr = currentGeometry.attributes.normal ? currentGeometry.attributes.normal.array : null;
2317+
const triCount = (posArr.length / 9) | 0;
2318+
const buf = new ArrayBuffer(84 + 50 * triCount);
2319+
const bytes = new Uint8Array(buf);
2320+
const view = new DataView(buf);
2321+
view.setUint32(80, triCount, true);
2322+
if (norArr) {
2323+
const posSrc = new Uint8Array(posArr.buffer, posArr.byteOffset, posArr.byteLength);
2324+
const norSrc = new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength);
2325+
for (let i = 0; i < triCount; i++) {
2326+
const dst = 84 + i * 50, srcOff = i * 36;
2327+
bytes.set(norSrc.subarray(srcOff, srcOff + 12), dst);
2328+
bytes.set(posSrc.subarray(srcOff, srcOff + 36), dst + 12);
2329+
}
2330+
}
2331+
zipFiles['model.stl'] = new Uint8Array(buf);
2332+
}
2333+
2334+
// Custom texture as PNG
2335+
if (includeTexture && activeMapEntry.fullCanvas) {
2336+
const blob = await new Promise(r => activeMapEntry.fullCanvas.toBlob(r, 'image/png'));
2337+
const arrBuf = await blob.arrayBuffer();
2338+
zipFiles['texture.png'] = new Uint8Array(arrBuf);
2339+
}
2340+
2341+
// Create ZIP and download
2342+
const zipped = zipSync(zipFiles);
2343+
const blob = new Blob([zipped], { type: 'application/octet-stream' });
2344+
const url = URL.createObjectURL(blob);
2345+
const a = document.createElement('a');
2346+
a.href = url;
2347+
a.download = (currentStlName || 'bumpmesh') + '.bumpmesh';
2348+
a.style.display = 'none';
2349+
document.body.appendChild(a);
2350+
a.click();
2351+
document.body.removeChild(a);
2352+
setTimeout(() => URL.revokeObjectURL(url), 10000);
2353+
});
2354+
2355+
// Import: file input handler
2356+
importInput.addEventListener('change', async (e) => {
2357+
const file = e.target.files[0];
2358+
if (!file) return;
2359+
importInput.value = ''; // reset for re-import
2360+
try {
2361+
await importBumpmesh(file);
2362+
} catch (err) {
2363+
alert(t('alerts.importFailed', { msg: err.message }));
2364+
}
2365+
});
2366+
2367+
async function importBumpmesh(file) {
2368+
const buf = await file.arrayBuffer();
2369+
const unzipped = unzipSync(new Uint8Array(buf));
2370+
2371+
// 1. Settings
2372+
if (unzipped['settings.json']) {
2373+
const json = JSON.parse(strFromU8(unzipped['settings.json']));
2374+
if (json.settings) {
2375+
// Apply settings to sliders/controls
2376+
for (const [key, value] of Object.entries(json.settings)) {
2377+
if (key in settings) settings[key] = value;
2378+
}
2379+
// Update all UI elements to reflect new settings
2380+
_syncUIFromSettings();
2381+
}
2382+
// Select texture preset if it matches
2383+
if (json.texture && !unzipped['texture.png']) {
2384+
_selectPresetByName(json.texture);
2385+
}
2386+
}
2387+
2388+
// 2. Model
2389+
if (unzipped['model.stl']) {
2390+
const stlBlob = new Blob([unzipped['model.stl']], { type: 'application/octet-stream' });
2391+
const stlFile = new File([stlBlob], 'model.stl');
2392+
await handleModelFile(stlFile);
2393+
}
2394+
2395+
// 3. Custom texture
2396+
if (unzipped['texture.png']) {
2397+
const texBlob = new Blob([unzipped['texture.png']], { type: 'image/png' });
2398+
const texFile = new File([texBlob], 'custom-texture.png');
2399+
activeMapEntry = await loadCustomTexture(texFile);
2400+
activeMapEntry.isCustom = true;
2401+
// Update preview
2402+
updatePreview();
2403+
}
2404+
}
2405+
2406+
// ── Helper: Sync UI from Settings ────────────────────────────────────────────
2407+
2408+
function _syncUIFromSettings() {
2409+
// Mapping mode
2410+
if (mappingSelect) mappingSelect.value = settings.mappingMode;
2411+
capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none';
2412+
2413+
// Scale sliders (logarithmic — convert value to slider position)
2414+
scaleUSlider.value = scaleToPos(settings.scaleU);
2415+
scaleUVal.value = settings.scaleU;
2416+
scaleVSlider.value = scaleToPos(settings.scaleV);
2417+
scaleVVal.value = settings.scaleV;
2418+
2419+
// Linear sliders + their value displays
2420+
const sliderMap = {
2421+
'amplitude': 'amplitude',
2422+
'offset-u': 'offsetU',
2423+
'offset-v': 'offsetV',
2424+
'rotation': 'rotation',
2425+
'refine-length': 'refineLength',
2426+
'bottom-angle-limit': 'bottomAngleLimit',
2427+
'top-angle-limit': 'topAngleLimit',
2428+
'seam-blend': 'mappingBlend',
2429+
'seam-band-width': 'seamBandWidth',
2430+
'texture-smoothing': 'textureSmoothing',
2431+
'cap-angle': 'capAngle',
2432+
};
2433+
for (const [sliderId, settingKey] of Object.entries(sliderMap)) {
2434+
const slider = document.getElementById(sliderId);
2435+
if (slider) {
2436+
slider.value = settings[settingKey];
2437+
slider.dispatchEvent(new Event('input', { bubbles: true }));
2438+
}
2439+
}
2440+
2441+
// Checkboxes
2442+
if (symmetricDispToggle) symmetricDispToggle.checked = settings.symmetricDisplacement;
2443+
2444+
// Lock scale button
2445+
if (lockScaleBtn) {
2446+
lockScaleBtn.classList.toggle('active', settings.lockScale);
2447+
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
2448+
}
2449+
2450+
// Max triangles slider
2451+
if (maxTriSlider) {
2452+
maxTriSlider.value = settings.maxTriangles;
2453+
maxTriSlider.dispatchEvent(new Event('input', { bubbles: true }));
2454+
}
2455+
}
2456+
2457+
// ── Helper: Select Preset by Name ────────────────────────────────────────────
2458+
2459+
function _selectPresetByName(name) {
2460+
const swatches = document.querySelectorAll('.preset-swatch');
2461+
for (const swatch of swatches) {
2462+
if (swatch.title === name || swatch.querySelector('.preset-label')?.textContent === name) {
2463+
swatch.click();
2464+
return;
2465+
}
2466+
}
2467+
}
2468+
2469+
// ── Auto-Save (localStorage) ─────────────────────────────────────────────────
2470+
2471+
const STORAGE_KEY = 'bumpmesh-settings';
2472+
2473+
function _saveToLocalStorage() {
2474+
const data = {
2475+
version: 1,
2476+
texture: activeMapEntry ? activeMapEntry.name : null,
2477+
settings: { ...settings },
2478+
};
2479+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { /* quota exceeded, ignore */ }
2480+
}
2481+
2482+
function _loadFromLocalStorage() {
2483+
try {
2484+
const raw = localStorage.getItem(STORAGE_KEY);
2485+
if (!raw) return;
2486+
const data = JSON.parse(raw);
2487+
if (data.settings) {
2488+
for (const [key, value] of Object.entries(data.settings)) {
2489+
if (key in settings) settings[key] = value;
2490+
}
2491+
// Defer UI sync until DOM is ready
2492+
requestAnimationFrame(() => {
2493+
_syncUIFromSettings();
2494+
if (data.texture) _selectPresetByName(data.texture);
2495+
});
2496+
}
2497+
} catch (e) { /* corrupted data, ignore */ }
2498+
}
2499+
2500+
// Restore settings from localStorage on startup
2501+
_loadFromLocalStorage();

style.css

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1139,4 +1139,70 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
11391139
background: var(--border);
11401140
border-color: var(--accent);
11411141
color: var(--text);
1142-
}
1142+
}
1143+
1144+
/* ── Preset I/O (export/import settings) ──────────────────────────────── */
1145+
.preset-io {
1146+
position: relative;
1147+
display: flex;
1148+
align-items: center;
1149+
gap: 2px;
1150+
}
1151+
.icon-btn {
1152+
display: flex;
1153+
align-items: center;
1154+
justify-content: center;
1155+
width: 32px;
1156+
height: 32px;
1157+
border: none;
1158+
background: transparent;
1159+
color: inherit;
1160+
cursor: pointer;
1161+
border-radius: 6px;
1162+
transition: background 0.15s;
1163+
}
1164+
.icon-btn:hover { background: rgba(255,255,255,0.1); }
1165+
[data-theme="light"] .icon-btn:hover { background: rgba(0,0,0,0.08); }
1166+
1167+
.export-dialog {
1168+
position: absolute;
1169+
top: 100%;
1170+
right: 0;
1171+
margin-top: 6px;
1172+
background: var(--bg-sidebar, #1e1e2e);
1173+
border: 1px solid rgba(255,255,255,0.12);
1174+
border-radius: 8px;
1175+
padding: 10px 14px;
1176+
display: flex;
1177+
flex-direction: column;
1178+
gap: 6px;
1179+
min-width: 180px;
1180+
z-index: 100;
1181+
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
1182+
}
1183+
.export-dialog.hidden { display: none; }
1184+
[data-theme="light"] .export-dialog {
1185+
background: #fff;
1186+
border-color: rgba(0,0,0,0.12);
1187+
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
1188+
}
1189+
.export-dialog label {
1190+
display: flex;
1191+
align-items: center;
1192+
gap: 6px;
1193+
font-size: 0.85em;
1194+
cursor: pointer;
1195+
}
1196+
.export-go-btn {
1197+
margin-top: 4px;
1198+
padding: 5px 12px;
1199+
border: none;
1200+
border-radius: 5px;
1201+
background: var(--accent, #6c8cff);
1202+
color: #fff;
1203+
cursor: pointer;
1204+
font-size: 0.85em;
1205+
font-weight: 500;
1206+
transition: background 0.15s;
1207+
}
1208+
.export-go-btn:hover { filter: brightness(1.15); }

0 commit comments

Comments
 (0)