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;
+}