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
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@
<span>BumpMesh <small style="opacity:.6;font-weight:400">by <a href="https://www.youtube.com/@CNCKitchen" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline">CNC Kitchen</a></small></span>
</div>
<div class="header-actions">
<div class="preset-io">
<button id="export-settings-btn" class="icon-btn" data-i18n-title="header.exportSettings" title="Export settings">
<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>
</button>
<input type="file" id="import-settings-input" accept=".bumpmesh,.json" hidden />
<label for="import-settings-input" class="icon-btn" data-i18n-title="header.importSettings" title="Import settings" role="button" tabindex="0">
<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>
</label>
<div id="export-dialog" class="export-dialog hidden">
<label><input type="checkbox" id="export-settings-chk" checked disabled /> <span data-i18n="header.exportSettingsLabel">Settings</span></label>
<label><input type="checkbox" id="export-model-chk" /> <span data-i18n="header.exportModelLabel">Model (STL)</span></label>
<label id="export-texture-row" class="hidden"><input type="checkbox" id="export-texture-chk" /> <span data-i18n="header.exportTextureLabel">Custom Texture</span></label>
<button id="export-go-btn" class="export-go-btn" data-i18n="header.exportGo">Export</button>
</div>
</div>
<div class="lang-seg">
</div>
<button id="theme-toggle" class="theme-toggle"
Expand Down
11 changes: 10 additions & 1 deletion js/i18n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,14 @@ export default {
"cta.storeDismiss": "Ausblenden",
"alerts.loadFailed": "Modell konnte nicht geladen werden: {msg}",
"alerts.exportFailed": "Export fehlgeschlagen: {msg}",
"alerts.fileTooLarge": "Datei zu gross ({size} MB). Maximum: {max} MB."
"alerts.fileTooLarge": "Datei zu gross ({size} MB). Maximum: {max} MB.",
"header.exportSettings": "Einstellungen exportieren",
"header.importSettings": "Einstellungen importieren",
"header.exportSettingsLabel": "Einstellungen",
"header.exportModelLabel": "Modell (STL)",
"header.exportTextureLabel": "Eigene Textur",
"header.exportGo": "Exportieren",
"alerts.importSuccess": "Einstellungen erfolgreich importiert",
"alerts.importFailed": "Import fehlgeschlagen: {msg}",
"alerts.importNoFile": "Keine .bumpmesh-Datei ausgewaehlt"
};
11 changes: 10 additions & 1 deletion js/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,14 @@ export default {
"cta.storeDismiss": "Dismiss",
"alerts.loadFailed": "Could not load model: {msg}",
"alerts.exportFailed": "Export failed: {msg}",
"alerts.fileTooLarge": "File too large ({size} MB). Maximum: {max} MB."
"alerts.fileTooLarge": "File too large ({size} MB). Maximum: {max} MB.",
"header.exportSettings": "Export settings",
"header.importSettings": "Import settings",
"header.exportSettingsLabel": "Settings",
"header.exportModelLabel": "Model (STL)",
"header.exportTextureLabel": "Custom Texture",
"header.exportGo": "Export",
"alerts.importSuccess": "Settings imported successfully",
"alerts.importFailed": "Failed to import settings: {msg}",
"alerts.importNoFile": "No .bumpmesh file selected"
};
251 changes: 251 additions & 0 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { exportSTL } from './exporter.js';
import { buildAdjacency, bucketFill,
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';

// ── State ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -442,6 +443,8 @@ function wireEvents() {
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const bmFile = [...e.dataTransfer.files].find(f => /\.bumpmesh$/i.test(f.name));
if (bmFile) { importBumpmesh(bmFile); return; }
const file = [...e.dataTransfer.files].find(f => /\.(stl|obj|3mf)$/i.test(f.name));
if (file) handleModelFile(file);
});
Expand Down Expand Up @@ -503,6 +506,8 @@ function wireEvents() {
scaleVSlider.value = scaleToPos(settings.scaleU);
scaleVVal.value = settings.scaleU;
updatePreview();
} else {
_saveToLocalStorage();
}
});

Expand Down Expand Up @@ -1435,6 +1440,8 @@ function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
} else {
_saveToLocalStorage();
}
});
// Double-click resets to default value
Expand All @@ -1446,6 +1453,8 @@ function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
} else {
_saveToLocalStorage();
}
});
if (!isSpan) {
Expand Down Expand Up @@ -2285,6 +2294,8 @@ function updatePreview() {

syncBoundaryEdgeUniforms();
exportBtn.disabled = false;

_saveToLocalStorage();
}

// ── Displacement preview ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -2893,3 +2904,243 @@ function runAsync(fn) {
function yieldFrame() {
return new Promise(r => requestAnimationFrame(r));
}

// ── Export/Import Settings (.bumpmesh) ───────────────────────────────────────

const exportSettingsBtn = document.getElementById('export-settings-btn');
const exportDialog = document.getElementById('export-dialog');
const exportGoBtn = document.getElementById('export-go-btn');
const exportModelChk = document.getElementById('export-model-chk');
const exportTextureChk = document.getElementById('export-texture-chk');
const exportTextureRow = document.getElementById('export-texture-row');
const importInput = document.getElementById('import-settings-input');

// Export dialog toggle
exportSettingsBtn.addEventListener('click', () => {
exportDialog.classList.toggle('hidden');
// Show texture checkbox only if custom texture is loaded
exportTextureRow.classList.toggle('hidden', !activeMapEntry || !activeMapEntry.isCustom);
// Enable model checkbox only if a model is loaded
exportModelChk.disabled = !currentGeometry;
});

// Close dialog when clicking outside
document.addEventListener('click', (e) => {
if (!exportDialog.contains(e.target) && e.target !== exportSettingsBtn && !exportSettingsBtn.contains(e.target)) {
exportDialog.classList.add('hidden');
}
});

// Export: build .bumpmesh ZIP and download
exportGoBtn.addEventListener('click', async () => {
exportDialog.classList.add('hidden');

const includeModel = exportModelChk.checked && currentGeometry;
const includeTexture = exportTextureChk.checked && activeMapEntry && activeMapEntry.isCustom;

const { useDisplacement: _, ...persistSettings } = settings;
const data = {
version: 1,
texture: activeMapEntry ? activeMapEntry.name : null,
settings: persistSettings,
};

const zipFiles = {};

// Settings JSON (always included)
zipFiles['settings.json'] = strToU8(JSON.stringify(data, null, 2));

// Model as binary STL
if (includeModel) {
const posArr = currentGeometry.attributes.position.array;
const norArr = currentGeometry.attributes.normal ? currentGeometry.attributes.normal.array : null;
const triCount = (posArr.length / 9) | 0;
const buf = new ArrayBuffer(84 + 50 * triCount);
const bytes = new Uint8Array(buf);
const view = new DataView(buf);
view.setUint32(80, triCount, true);
if (norArr) {
const posSrc = new Uint8Array(posArr.buffer, posArr.byteOffset, posArr.byteLength);
const norSrc = new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength);
for (let i = 0; i < triCount; i++) {
const dst = 84 + i * 50, srcOff = i * 36;
bytes.set(norSrc.subarray(srcOff, srcOff + 12), dst);
bytes.set(posSrc.subarray(srcOff, srcOff + 36), dst + 12);
}
}
zipFiles['model.stl'] = new Uint8Array(buf);
}

// Custom texture as PNG
if (includeTexture && activeMapEntry.fullCanvas) {
const blob = await new Promise(r => activeMapEntry.fullCanvas.toBlob(r, 'image/png'));
const arrBuf = await blob.arrayBuffer();
zipFiles['texture.png'] = new Uint8Array(arrBuf);
}

// Create ZIP and download
const zipped = zipSync(zipFiles);
const blob = new Blob([zipped], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentStlName || 'bumpmesh') + '.bumpmesh';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
});

// Import: file input handler
importInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
importInput.value = ''; // reset for re-import
try {
await importBumpmesh(file);
} catch (err) {
alert(t('alerts.importFailed', { msg: err.message }));
}
});

async function importBumpmesh(file) {
const MAX_IMPORT_SIZE = 500 * 1024 * 1024; // 500 MB
if (file.size > MAX_IMPORT_SIZE) {
alert(t('alerts.importFailed', { msg: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB)` }));
return;
}

const buf = await file.arrayBuffer();
const unzipped = unzipSync(new Uint8Array(buf));

const json = unzipped['settings.json']
? JSON.parse(strFromU8(unzipped['settings.json']))
: null;

// 1. Model first (handleModelFile resets some settings)
if (unzipped['model.stl']) {
const stlBlob = new Blob([unzipped['model.stl']], { type: 'application/octet-stream' });
const stlFile = new File([stlBlob], 'model.stl');
await handleModelFile(stlFile);
}

// 2. Settings after model load (overrides any resets from handleModelFile)
if (json && json.settings) {
for (const [key, value] of Object.entries(json.settings)) {
if (key === 'useDisplacement') continue;
if (key in settings) settings[key] = value;
}
_syncUIFromSettings();
}

// 3. Texture preset or custom texture
if (unzipped['texture.png']) {
const texBlob = new Blob([unzipped['texture.png']], { type: 'image/png' });
const texFile = new File([texBlob], 'custom-texture.png');
activeMapEntry = await loadCustomTexture(texFile);
activeMapEntry.isCustom = true;
updatePreview();
} else if (json && json.texture) {
_selectPresetByName(json.texture);
}
}

// ── Helper: Sync UI from Settings ────────────────────────────────────────────

function _syncUIFromSettings() {
// Mapping mode
if (mappingSelect) mappingSelect.value = settings.mappingMode;
capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none';

// Scale sliders (logarithmic — convert value to slider position)
scaleUSlider.value = scaleToPos(settings.scaleU);
scaleUVal.value = settings.scaleU;
scaleVSlider.value = scaleToPos(settings.scaleV);
scaleVVal.value = settings.scaleV;

// Linear sliders + their value displays
const sliderMap = {
'amplitude': 'amplitude',
'offset-u': 'offsetU',
'offset-v': 'offsetV',
'rotation': 'rotation',
'refine-length': 'refineLength',
'bottom-angle-limit': 'bottomAngleLimit',
'top-angle-limit': 'topAngleLimit',
'seam-blend': 'mappingBlend',
'seam-band-width': 'seamBandWidth',
'texture-smoothing': 'textureSmoothing',
'cap-angle': 'capAngle',
'boundary-falloff': 'boundaryFalloff',
};
for (const [sliderId, settingKey] of Object.entries(sliderMap)) {
const slider = document.getElementById(sliderId);
if (slider) {
slider.value = settings[settingKey];
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
}

// Checkboxes
if (symmetricDispToggle) symmetricDispToggle.checked = settings.symmetricDisplacement;

// Lock scale button
if (lockScaleBtn) {
lockScaleBtn.classList.toggle('active', settings.lockScale);
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
}

// Max triangles slider
if (maxTriSlider) {
maxTriSlider.value = settings.maxTriangles;
maxTriSlider.dispatchEvent(new Event('input', { bubbles: true }));
}
}

// ── Helper: Select Preset by Name ────────────────────────────────────────────

function _selectPresetByName(name) {
const swatches = document.querySelectorAll('.preset-swatch');
for (const swatch of swatches) {
if (swatch.title === name || swatch.querySelector('.preset-label')?.textContent === name) {
swatch.click();
return;
}
}
}

// ── Auto-Save (localStorage) ─────────────────────────────────────────────────

const STORAGE_KEY = 'bumpmesh-settings';

function _saveToLocalStorage() {
const { useDisplacement: _, ...persistSettings } = settings;
const data = {
version: 1,
texture: activeMapEntry ? activeMapEntry.name : null,
settings: persistSettings,
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { /* quota exceeded, ignore */ }
}

function _loadFromLocalStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.settings) {
for (const [key, value] of Object.entries(data.settings)) {
if (key in settings) settings[key] = value;
}
// Defer UI sync until DOM is ready
requestAnimationFrame(() => {
_syncUIFromSettings();
if (data.texture) _selectPresetByName(data.texture);
});
}
} catch (e) { /* corrupted data, ignore */ }
}

// Restore settings from localStorage on startup
_loadFromLocalStorage();
Loading