From ed2f3522fb32565c66b602d7bf6e6a64a1a7f611 Mon Sep 17 00:00:00 2001 From: Henley Bailey Date: Wed, 22 Apr 2026 22:43:08 +0100 Subject: [PATCH] feat: add settings preset save/load Adds a Presets section to the sidebar for saving, loading, exporting, and importing named settings presets. Presets are stored in localStorage under 'bumpmesh_presets_v1'. Export/import as JSON enables sharing between devices. Scope: captures all slider/select/checkbox settings plus the active built-in texture name. Surface paint masks and custom uploaded textures are intentionally out of scope. - New js/presets.js module (UI + localStorage, decoupled from app internals) - main.js: hoists _applyScaleV, adds getSettingsSnapshot/applySettingsSnapshot, wires initPresets - index.html: empty panel-section placeholder populated at runtime - style.css: preset UI styles matching existing variable set - i18n: en + de translations added; other 6 languages fall back to en --- .gitignore | 3 +- index.html | 5 ++ js/i18n/de.js | 16 ++++- js/i18n/en.js | 16 ++++- js/main.js | 115 +++++++++++++++++++++++++++++---- js/presets.js | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 117 +++++++++++++++++++++++++++++++++- 7 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 js/presets.js diff --git a/.gitignore b/.gitignore index 5ed7592..b7fec19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ assets/ -.claude/ \ No newline at end of file +.claude/ +.idea/ diff --git a/index.html b/index.html index 59b3add..39ca118 100644 --- a/index.html +++ b/index.html @@ -137,6 +137,11 @@

All processing runs locally in your browser — no data is uploaded.

+ +
+ +
+

Displacement Map

diff --git a/js/i18n/de.js b/js/i18n/de.js index c972c81..8368805 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -168,5 +168,19 @@ export default { "diag.advancedOk": "\u2714 Keine Schnitte oder Überlappungen gefunden", "diag.recommendFix": "Beheben Sie diese Probleme in Ihrer CAD-Software, Ihrem Slicer oder online vor dem Texturieren.", "diag.show": "Zeigen", - "diag.hide": "Ausbl." + "diag.hide": "Ausbl.", + "sections.presets": "Voreinstellungen ⓘ", + "tooltips.presets": "Speichern Sie Ihre aktuellen Einstellungen als benannte Voreinstellung zur Wiederverwendung. Voreinstellungen werden in Ihrem Browser gespeichert und können zur Sicherung oder Weitergabe als JSON exportiert werden.", + "presets.namePlaceholder": "Name der Voreinstellung…", + "presets.save": "Speichern", + "presets.saveTitle": "Aktuelle Einstellungen als Voreinstellung speichern", + "presets.load": "Laden", + "presets.loadTitle": "Diese Einstellungen wiederherstellen", + "presets.deleteTitle": "Voreinstellung löschen", + "presets.export": "JSON exportieren", + "presets.exportTitle": "Alle Voreinstellungen als JSON-Datei exportieren", + "presets.import": "JSON importieren", + "presets.importTitle": "Voreinstellungen aus einer JSON-Datei importieren", + "presets.empty": "Noch keine Voreinstellungen gespeichert.", + "presets.importError": "Voreinstellungen konnten nicht importiert werden – stellen Sie sicher, dass die Datei ein gültiges BumpMesh-Preset-JSON ist." }; diff --git a/js/i18n/en.js b/js/i18n/en.js index d9b0fbd..b4f90a9 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -168,5 +168,19 @@ export default { "diag.advancedOk": "\u2714 No intersections or overlaps found", "diag.recommendFix": "Fix these issues in your CAD software, slicer, or online before texturing.", "diag.show": "Show", - "diag.hide": "Hide" + "diag.hide": "Hide", + "sections.presets": "Presets ⓘ", + "tooltips.presets": "Save your current settings as a named preset for reuse. Presets are stored in your browser and can be exported as JSON for backup or sharing.", + "presets.namePlaceholder": "Preset name…", + "presets.save": "Save", + "presets.saveTitle": "Save current settings as a preset", + "presets.load": "Load", + "presets.loadTitle": "Restore these settings", + "presets.deleteTitle": "Delete preset", + "presets.export": "Export JSON", + "presets.exportTitle": "Export all presets as a JSON file", + "presets.import": "Import JSON", + "presets.importTitle": "Import presets from a JSON file", + "presets.empty": "No presets saved yet.", + "presets.importError": "Could not import presets — make sure the file is a valid BumpMesh preset JSON." }; diff --git a/js/main.js b/js/main.js index 17d3cba..892dbea 100644 --- a/js/main.js +++ b/js/main.js @@ -17,6 +17,7 @@ import { buildAdjacency, bucketFill, import { runFastDiagnostics, runExpensiveDiagnostics, getEdgePositions, getShellAssignments } from './meshValidation.js'; import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; +import { initPresets } from './presets.js'; // ── State ───────────────────────────────────────────────────────────────────── @@ -319,6 +320,15 @@ function _applyScaleU(v) { clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); } +function _applyScaleV(v) { + v = Math.max(0.01, Math.min(10, v)); + settings.scaleV = v; + scaleVSlider.value = scaleToPos(v); + scaleVVal.value = v; + if (settings.lockScale) { settings.scaleU = v; scaleUSlider.value = scaleToPos(v); scaleUVal.value = v; } + clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); +} + // ── Init ────────────────────────────────────────────────────────────────────── let PRESETS = []; @@ -415,6 +425,93 @@ wireEvents(); scaleUVal.value = posToScale(parseFloat(scaleUSlider.value)); scaleVVal.value = posToScale(parseFloat(scaleVSlider.value)); +// ── Preset save/load state helpers ──────────────────────────────────────────── + +function getSettingsSnapshot() { + return { + mappingMode: settings.mappingMode, + scaleU: settings.scaleU, + scaleV: settings.scaleV, + lockScale: settings.lockScale, + offsetU: settings.offsetU, + offsetV: settings.offsetV, + rotation: settings.rotation, + amplitude: settings.amplitude, + textureHeight: settings.textureHeight, + invertDisplacement: settings.invertDisplacement, + symmetricDisplacement: settings.symmetricDisplacement, + textureSmoothing: settings.textureSmoothing, + mappingBlend: settings.mappingBlend, + seamBandWidth: settings.seamBandWidth, + capAngle: settings.capAngle, + boundaryFalloff: settings.boundaryFalloff, + bottomAngleLimit: settings.bottomAngleLimit, + topAngleLimit: settings.topAngleLimit, + refineLength: settings.refineLength, + maxTriangles: settings.maxTriangles, + activeMapName: activeMapEntry ? activeMapEntry.name : null, + }; +} + +function applySettingsSnapshot(snap) { + // Mapping mode + if (snap.mappingMode != null) { + mappingSelect.value = String(snap.mappingMode); + mappingSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + + // Scale lock — set state before applying scale values + if (snap.lockScale != null && snap.lockScale !== settings.lockScale) { + lockScaleBtn.click(); + } + + // Scale U/V — use hoisted helpers (handle log-scale transform + lock mirroring) + if (snap.scaleU != null) _applyScaleU(snap.scaleU); + if (snap.scaleV != null && !settings.lockScale) _applyScaleV(snap.scaleV); + + // Sliders wired via linkSlider: set the val input and fire 'change' + // so applyLinkedValue runs, which updates settings + slider position + preview + const setLinkedVal = (inputEl, value) => { + if (inputEl && value != null) { + inputEl.value = value; + inputEl.dispatchEvent(new Event('change', { bubbles: true })); + } + }; + setLinkedVal(offsetUVal, snap.offsetU); + setLinkedVal(offsetVVal, snap.offsetV); + setLinkedVal(rotationVal, snap.rotation); + setLinkedVal(amplitudeVal, snap.amplitude); + setLinkedVal(textureSmoothingVal, snap.textureSmoothing); + setLinkedVal(seamBlendVal, snap.mappingBlend); + setLinkedVal(seamBandWidthVal, snap.seamBandWidth); + setLinkedVal(capAngleVal, snap.capAngle); + setLinkedVal(boundaryFalloffVal, snap.boundaryFalloff); + setLinkedVal(bottomAngleLimitVal, snap.bottomAngleLimit); + setLinkedVal(topAngleLimitVal, snap.topAngleLimit); + setLinkedVal(refineLenVal, snap.refineLength); + + // max-triangles uses a val, so drive the slider directly + if (snap.maxTriangles != null) { + maxTriSlider.value = snap.maxTriangles; + maxTriSlider.dispatchEvent(new Event('input', { bubbles: true })); + } + + // Checkboxes + if (snap.symmetricDisplacement != null) { + symmetricDispToggle.checked = snap.symmetricDisplacement; + symmetricDispToggle.dispatchEvent(new Event('change', { bubbles: true })); + } + + // Texture preset — find swatch whose title matches, click it + if (snap.activeMapName) { + const swatch = Array.from(document.querySelectorAll('.preset-swatch')) + .find(s => s.title === snap.activeMapName); + if (swatch) swatch.click(); + } +} + +initPresets({ getState: getSettingsSnapshot, applyState: applySettingsSnapshot, t }); + // Load geometry immediately — don't wait for textures loadDefaultCube(); @@ -619,19 +716,11 @@ function wireEvents() { scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value))); addFineWheelSupport(scaleUVal, applyScaleU); - // Scale V — when lock is on, mirror to U - const applyScaleV = (v) => { - v = Math.max(0.01, Math.min(10, v)); - settings.scaleV = v; - scaleVSlider.value = scaleToPos(v); - scaleVVal.value = v; - if (settings.lockScale) { settings.scaleU = v; scaleUSlider.value = scaleToPos(v); scaleUVal.value = v; } - clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); - }; - scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value)))); - scaleVSlider.addEventListener('dblclick', () => applyScaleV(posToScale(parseFloat(scaleVSlider.defaultValue)))); - scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value))); - addFineWheelSupport(scaleVVal, applyScaleV); + // Scale V — when lock is on, mirror to U (uses hoisted _applyScaleV) + scaleVSlider.addEventListener('input', () => _applyScaleV(posToScale(parseFloat(scaleVSlider.value)))); + scaleVSlider.addEventListener('dblclick', () => _applyScaleV(posToScale(parseFloat(scaleVSlider.defaultValue)))); + scaleVVal.addEventListener('change', () => _applyScaleV(parseFloat(scaleVVal.value))); + addFineWheelSupport(scaleVVal, _applyScaleV); // Lock toggle lockScaleBtn.addEventListener('click', () => { diff --git a/js/presets.js b/js/presets.js new file mode 100644 index 0000000..85dcd01 --- /dev/null +++ b/js/presets.js @@ -0,0 +1,171 @@ +/** + * presets.js — Save / load named settings presets. + * + * Exports a single `initPresets(deps)` function. Call it once after the main + * UI is wired. All state read/write goes through the two callbacks so this + * module stays decoupled from the rest of main.js. + * + * deps: + * getState() → plain object snapshot of all current settings + * applyState(snap) → restore settings from a snapshot object + * t(key) → i18n helper + */ + +const STORAGE_KEY = 'bumpmesh_presets_v1'; + +function loadFromStorage() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } + catch { return {}; } +} + +function saveToStorage(presets) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +export function initPresets({ getState, applyState, t }) { + + // ── Build section HTML ──────────────────────────────────────────────────── + + const section = document.getElementById('presets-section'); + section.innerHTML = ` +

Presets ⓘ

+
+ + +
+
+
+ + +
+ `; + + // ── Element refs ───────────────────────────────────────────────────────── + + const nameInput = document.getElementById('preset-name-input'); + const saveBtn = document.getElementById('preset-save-btn'); + const listEl = document.getElementById('preset-list'); + const exportBtn = document.getElementById('preset-export-btn'); + const importInput = document.getElementById('preset-import-input'); + + // Apply translated text that can't be set via data-i18n attributes + nameInput.placeholder = t('presets.namePlaceholder'); + + // ── Render preset list ──────────────────────────────────────────────────── + + function render() { + const presets = loadFromStorage(); + const names = Object.keys(presets); + listEl.innerHTML = ''; + + if (names.length === 0) { + const hint = document.createElement('p'); + hint.className = 'preset-empty-hint'; + hint.setAttribute('data-i18n', 'presets.empty'); + hint.textContent = t('presets.empty'); + listEl.appendChild(hint); + return; + } + + for (const name of names) { + const row = document.createElement('div'); + row.className = 'preset-row'; + + const label = document.createElement('span'); + label.className = 'preset-row-name'; + label.textContent = name; + label.title = name; + + const loadBtn = document.createElement('button'); + loadBtn.className = 'preset-action-btn preset-load-btn'; + loadBtn.textContent = t('presets.load'); + loadBtn.setAttribute('data-i18n', 'presets.load'); + loadBtn.addEventListener('click', () => applyState(presets[name])); + + const delBtn = document.createElement('button'); + delBtn.className = 'preset-action-btn preset-delete-btn'; + delBtn.textContent = '✕'; + delBtn.title = t('presets.deleteTitle'); + delBtn.setAttribute('aria-label', t('presets.deleteTitle')); + delBtn.addEventListener('click', () => { + const all = loadFromStorage(); + delete all[name]; + saveToStorage(all); + render(); + }); + + row.appendChild(label); + row.appendChild(loadBtn); + row.appendChild(delBtn); + listEl.appendChild(row); + } + } + + // ── Save ───────────────────────────────────────────────────────────────── + + function savePreset() { + const name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + const all = loadFromStorage(); + all[name] = getState(); + saveToStorage(all); + nameInput.value = ''; + render(); + } + + saveBtn.addEventListener('click', savePreset); + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') savePreset(); + }); + + // ── Export ──────────────────────────────────────────────────────────────── + + exportBtn.addEventListener('click', () => { + const json = JSON.stringify(loadFromStorage(), null, 2); + const a = document.createElement('a'); + a.href = 'data:application/json,' + encodeURIComponent(json); + a.download = 'bumpmesh-presets.json'; + a.click(); + }); + + // ── Import ──────────────────────────────────────────────────────────────── + + importInput.addEventListener('change', function () { + const file = this.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const imported = JSON.parse(e.target.result); + if (typeof imported !== 'object' || Array.isArray(imported) || imported === null) + throw new Error('not an object'); + const merged = { ...loadFromStorage(), ...imported }; + saveToStorage(merged); + render(); + } catch { + alert(t('presets.importError')); + } + this.value = ''; + }; + reader.readAsText(file); + }); + + // ── Initial render ──────────────────────────────────────────────────────── + + render(); +} diff --git a/style.css b/style.css index a85d566..95377e5 100644 --- a/style.css +++ b/style.css @@ -1399,4 +1399,119 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } background: var(--border); border-color: var(--accent); color: var(--text); -} \ No newline at end of file +} +/* ── Presets ─────────────────────────────────────────────────────────────── */ + +.preset-save-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.preset-name-input { + flex: 1; + min-width: 0; + padding: 5px 8px; + font-size: 12px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + transition: border-color 0.15s; +} + +.preset-name-input:focus { + outline: none; + border-color: var(--accent); +} + +.preset-action-btn { + padding: 5px 10px; + font-size: 12px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} + +.preset-action-btn:hover { + background: var(--border); + border-color: var(--accent); +} + +.preset-save-btn { + background: var(--accent); + border-color: transparent; + color: #fff; + font-weight: 600; +} + +.preset-save-btn:hover { + background: var(--accent-hover); + border-color: transparent; +} + +.preset-list { + margin-bottom: 8px; +} + +.preset-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.preset-row:last-child { + border-bottom: none; +} + +.preset-row-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.preset-load-btn { + background: var(--accent); + border-color: transparent; + color: #fff; +} + +.preset-load-btn:hover { + background: var(--accent-hover); + border-color: transparent; +} + +.preset-delete-btn { + padding: 5px 8px; + color: var(--text-muted); +} + +.preset-delete-btn:hover { + color: var(--text); +} + +.preset-io-row { + display: flex; + gap: 6px; +} + +.preset-import-label { + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.preset-empty-hint { + font-size: 11px; + color: var(--text-muted); + margin: 0 0 8px; +}