Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
assets/
.claude/
.claude/
.idea/
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@
<p style="margin:.4em 0 0;font-size:.75rem;opacity:.55;text-align:center;" data-i18n="ui.localProcessingNote">All processing runs locally in your browser — no data is uploaded.</p>
</section>

<!-- Presets -->
<section class="panel-section" id="presets-section">
<!-- populated by js/presets.js -->
</section>

<!-- Displacement Map -->
<section class="panel-section">
<h2 data-i18n="sections.displacementMap">Displacement Map</h2>
Expand Down
16 changes: 15 additions & 1 deletion js/i18n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href=\"https://www.formware.co/onlinestlrepair\" target=\"_blank\" rel=\"noopener\">online</a> 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."
};
16 changes: 15 additions & 1 deletion js/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href=\"https://www.formware.co/onlinestlrepair\" target=\"_blank\" rel=\"noopener\">online</a> 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."
};
115 changes: 102 additions & 13 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 <span> 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();

Expand Down Expand Up @@ -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', () => {
Expand Down
171 changes: 171 additions & 0 deletions js/presets.js
Original file line number Diff line number Diff line change
@@ -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 = `
<h2 data-i18n="sections.presets" data-i18n-title="tooltips.presets"
title="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 ⓘ</h2>
<div class="preset-save-row">
<input
type="text"
id="preset-name-input"
class="preset-name-input"
autocomplete="off"
maxlength="64"
/>
<button id="preset-save-btn" class="preset-action-btn preset-save-btn"
data-i18n="presets.save" data-i18n-title="presets.saveTitle"
title="Save current settings as a preset">Save</button>
</div>
<div id="preset-list" class="preset-list"></div>
<div class="preset-io-row">
<button id="preset-export-btn" class="preset-action-btn"
data-i18n="presets.export" data-i18n-title="presets.exportTitle"
title="Export all presets as a JSON file">Export JSON</button>
<label class="preset-action-btn preset-import-label"
data-i18n="presets.import" data-i18n-title="presets.importTitle"
title="Import presets from a JSON file">Import JSON
<input type="file" id="preset-import-input" accept=".json" hidden />
</label>
</div>
`;

// ── 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();
}
Loading