From 2702686bc17c8cc6b6b712bfb2612e03d2c1e6eb Mon Sep 17 00:00:00 2001 From: alan-polk <56047663+alan-polk@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:25:42 -0500 Subject: [PATCH] First attempt at profile feature. --- index.html | 37 ++++ js/i18n.js | 69 ++++++- js/main.js | 445 +++++++++++++++++++++++++++++++++++++++---- js/presetTextures.js | 30 +++ js/profiles.js | 72 +++++++ style.css | 181 ++++++++++++++++++ 6 files changed, 793 insertions(+), 41 deletions(-) create mode 100644 js/profiles.js diff --git a/index.html b/index.html index 9db2716..74f4479 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,26 @@ + + + + + Profile + + — + + + Save + + + Export… + + Import… + + + + + Displacement Map @@ -398,6 +418,23 @@ Privacy P + + + + × + Save profile + Enter a name. Saving overwrites an existing profile with the same name. + + Name + + + + Cancel + Save + + + + diff --git a/js/i18n.js b/js/i18n.js index c5880bd..d3de54e 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -21,6 +21,27 @@ export const TRANSLATIONS = { // Load model button 'ui.loadStl': 'Load Model\u2026', + // Profiles (texture + settings; saved in browser or as JSON file) + 'profile.label': 'Profile', + 'profile.selectTitle': 'Saved profiles and file import/export', + 'profile.optionNone': '\u2014', + 'profile.save': 'Save', + 'profile.saveTitle': 'Save current settings as a profile', + 'profile.exportFile': 'Export\u2026', + 'profile.exportFileTitle': 'Download profile as JSON (includes custom textures)', + 'profile.importFile': 'Import\u2026', + 'profile.importFileTitle': 'Load profile from JSON file', + 'profile.saveModalTitle': 'Save profile', + 'profile.saveModalHint': 'Enter a name. Saving overwrites an existing profile with the same name in this browser.', + 'profile.nameLabel': 'Name', + 'profile.cancel': 'Cancel', + 'profile.saveConfirm': 'Save', + 'profile.nameRequired': 'Please enter a profile name.', + 'profile.exportNeedsMap': 'Select or upload a displacement map before exporting a profile.', + 'profile.invalidFile': 'This file is not a valid BumpMesh profile.', + 'profile.presetNotFound': 'Preset texture \u201c{name}\u201d was not found. It may have been renamed in a newer version.', + 'profile.applyFailed': 'Could not apply profile: {msg}', + // Displacement map section 'sections.displacementMap': 'Displacement Map', 'ui.uploadCustomMap': 'Upload custom map', @@ -166,7 +187,7 @@ export const TRANSLATIONS = { 'imprint.sectionPrivacy': 'Privacy Policy (Datenschutzerkl\u00e4rung)', 'imprint.privacyIntro': 'Responsible party (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Germany.', 'imprint.privacyHosting': 'This website is hosted on GitHub Pages (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA). When you visit this site, GitHub may process your IP address in server logs. Legal basis: Art. 6(1)(f) DSGVO (legitimate interest in providing the website). See GitHub\u2019s Privacy Statement.', - 'imprint.privacyLocal': 'This tool stores user preferences (language, theme) in your browser\u2019s localStorage. This data never leaves your device and is not transmitted to any server.', + 'imprint.privacyLocal': 'This tool stores user preferences (language, theme, and saved profiles) in your browser\u2019s localStorage. This data never leaves your device and is not transmitted to any server.', 'imprint.privacyNoCookies': 'This website does not use cookies, analytics, or any tracking technologies.', 'imprint.privacyExternal': 'This site contains links to external websites (e.g., CNCKitchen.STORE, PayPal). These sites have their own privacy policies, over which we have no control.', 'imprint.privacyRights': 'Under the GDPR you have the right to access, rectification, erasure, restriction of processing, data portability, and the right to lodge a complaint with a supervisory authority.', @@ -207,6 +228,27 @@ export const TRANSLATIONS = { // Load model button 'ui.loadStl': 'Modell laden\u2026', + // Profiles + 'profile.label': 'Profil', + 'profile.selectTitle': 'Gespeicherte Profile und Datei Import/Export', + 'profile.optionNone': '\u2014', + 'profile.save': 'Speichern', + 'profile.saveTitle': 'Aktuelle Einstellungen als Profil speichern', + 'profile.exportFile': 'Exportieren\u2026', + 'profile.exportFileTitle': 'Profil als JSON herunterladen (inkl. eigener Texturen)', + 'profile.importFile': 'Importieren\u2026', + 'profile.importFileTitle': 'Profil aus JSON-Datei laden', + 'profile.saveModalTitle': 'Profil speichern', + 'profile.saveModalHint': 'Namen eingeben. Speichern \u00fcberschreibt ein vorhandenes Profil gleichen Namens in diesem Browser.', + 'profile.nameLabel': 'Name', + 'profile.cancel': 'Abbrechen', + 'profile.saveConfirm': 'Speichern', + 'profile.nameRequired': 'Bitte einen Profilnamen eingeben.', + 'profile.exportNeedsMap': 'Bitte zuerst eine Textur ausw\u00e4hlen oder hochladen, bevor Sie ein Profil exportieren.', + 'profile.invalidFile': 'Diese Datei ist kein g\u00fcltiges BumpMesh-Profil.', + 'profile.presetNotFound': 'Voreingestellte Textur \u201e{name}\u201c wurde nicht gefunden (evtl. umbenannt).', + 'profile.applyFailed': 'Profil konnte nicht angewendet werden: {msg}', + // Displacement map section 'sections.displacementMap': 'Textur', 'ui.uploadCustomMap': 'Eigene Textur hochladen', @@ -352,7 +394,7 @@ export const TRANSLATIONS = { 'imprint.sectionPrivacy': 'Datenschutzerkl\u00e4rung', 'imprint.privacyIntro': 'Verantwortlicher gem. Art. 4 Abs. 7 DSGVO: Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Deutschland.', 'imprint.privacyHosting': 'Diese Website wird auf GitHub Pages (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA) gehostet. Beim Besuch dieser Seite kann GitHub Ihre IP-Adresse in Server-Logs verarbeiten. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bereitstellung der Website). Siehe Datenschutzerkl\u00e4rung von GitHub.', - 'imprint.privacyLocal': 'Dieses Tool speichert Nutzereinstellungen (Sprache, Theme) im localStorage Ihres Browsers. Diese Daten verlassen Ihr Ger\u00e4t nicht und werden nicht an einen Server \u00fcbertragen.', + 'imprint.privacyLocal': 'Dieses Tool speichert Nutzereinstellungen (Sprache, Theme und gespeicherte Profile) im localStorage Ihres Browsers. Diese Daten verlassen Ihr Ger\u00e4t nicht und werden nicht an einen Server \u00fcbertragen.', 'imprint.privacyNoCookies': 'Diese Website verwendet keine Cookies, Analyse-Tools oder sonstige Tracking-Technologien.', 'imprint.privacyExternal': 'Diese Seite enth\u00e4lt Links zu externen Websites (z.B. CNCKitchen.STORE, PayPal). F\u00fcr deren Datenschutzrichtlinien \u00fcbernehmen wir keine Verantwortung.', 'imprint.privacyRights': 'Nach der DSGVO haben Sie das Recht auf Auskunft, Berichtigung, L\u00f6schung, Einschr\u00e4nkung der Verarbeitung, Daten\u00fcbertragbarkeit sowie das Recht auf Beschwerde bei einer Aufsichtsbeh\u00f6rde.', @@ -393,6 +435,27 @@ export const TRANSLATIONS = { // Load model button 'ui.loadStl': 'Carica Modello\u2026', + // Profiles + 'profile.label': 'Profilo', + 'profile.selectTitle': 'Profili salvati e import/export file', + 'profile.optionNone': '\u2014', + 'profile.save': 'Salva', + 'profile.saveTitle': 'Salva le impostazioni correnti come profilo', + 'profile.exportFile': 'Esporta\u2026', + 'profile.exportFileTitle': 'Scarica profilo come JSON (include texture personalizzate)', + 'profile.importFile': 'Importa\u2026', + 'profile.importFileTitle': 'Carica profilo da file JSON', + 'profile.saveModalTitle': 'Salva profilo', + 'profile.saveModalHint': 'Inserisci un nome. Salvare sovrascrive un profilo esistente con lo stesso nome in questo browser.', + 'profile.nameLabel': 'Nome', + 'profile.cancel': 'Annulla', + 'profile.saveConfirm': 'Salva', + 'profile.nameRequired': 'Inserisci un nome per il profilo.', + 'profile.exportNeedsMap': 'Seleziona o carica una mappa di spostamento prima di esportare un profilo.', + 'profile.invalidFile': 'Questo file non \u00e8 un profilo BumpMesh valido.', + 'profile.presetNotFound': 'Texture predefinita \u201c{name}\u201d non trovata.', + 'profile.applyFailed': 'Impossibile applicare il profilo: {msg}', + // Displacement map section 'sections.displacementMap': 'Mappa di Deformazione', 'ui.uploadCustomMap': 'Carica mappa personalizzata', @@ -539,7 +602,7 @@ export const TRANSLATIONS = { 'imprint.sectionPrivacy': 'Informativa sulla privacy (Datenschutzerklärung)', 'imprint.privacyIntro': 'Titolare del trattamento (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Germania.', 'imprint.privacyHosting': 'Questo sito web è ospitato su GitHub Pages (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA). Quando visiti questo sito, GitHub potrebbe elaborare il tuo indirizzo IP nei log del server. Base giuridica: Art. 6(1)(f) GDPR (interesse legittimo alla fornitura del sito web). Vedi Informativa sulla privacy di GitHub.', - 'imprint.privacyLocal': 'Questo strumento memorizza le preferenze dell\'utente (lingua, tema) nel localStorage del tuo browser. Questi dati non lasciano mai il tuo dispositivo e non vengono trasmessi a nessun server.', + 'imprint.privacyLocal': 'Questo strumento memorizza le preferenze dell\'utente (lingua, tema e profili salvati) nel localStorage del tuo browser. Questi dati non lasciano mai il tuo dispositivo e non vengono trasmessi a nessun server.', 'imprint.privacyNoCookies': 'Questo sito web non utilizza cookie, strumenti di analisi o tecnologie di tracciamento.', 'imprint.privacyExternal': 'Questo sito contiene link a siti web esterni (ad es. CNCKitchen.STORE, PayPal). Questi siti hanno le proprie politiche sulla privacy, sulle quali non abbiamo alcun controllo.', 'imprint.privacyRights': 'Ai sensi del GDPR hai il diritto di accesso, rettifica, cancellazione, limitazione del trattamento, portabilità dei dati e il diritto di presentare un reclamo presso un\'autorità di controllo.', diff --git a/js/main.js b/js/main.js index 018284e..009a104 100644 --- a/js/main.js +++ b/js/main.js @@ -3,7 +3,14 @@ import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWirefram getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; -import { loadPresets, loadCustomTexture } from './presetTextures.js'; +import { loadPresets, loadCustomTexture, loadTextureFromDataUrl } from './presetTextures.js'; +import { + buildProfilePayload, + validateProfile, + loadProfilesMap, + saveNamedProfile, + listProfileNames, +} from './profiles.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; import { subdivide } from './subdivision.js'; import { applyDisplacement } from './displacement.js'; @@ -239,6 +246,16 @@ const imprintLink = document.getElementById('imprint-link'); const imprintOverlay = document.getElementById('imprint-overlay'); const imprintClose = document.getElementById('imprint-close'); +const profileSelect = document.getElementById('profile-select'); +const profileSaveBtn = document.getElementById('profile-save-btn'); +const profileExportBtn = document.getElementById('profile-export-btn'); +const profileImportInput = document.getElementById('profile-import-input'); +const profileSaveOverlay = document.getElementById('profile-save-overlay'); +const profileSaveName = document.getElementById('profile-save-name'); +const profileSaveConfirm = document.getElementById('profile-save-confirm'); +const profileSaveCancel = document.getElementById('profile-save-cancel'); +const profileSaveClose = document.getElementById('profile-save-close'); + // ── Language selector DOM refs ──────────────────────────────────────────────────── const languageSelector = document.querySelector('.lang-seg'); @@ -263,6 +280,13 @@ function _applyScaleU(v) { let PRESETS = []; +// ── Profile (import/export + local named profiles) ─────────────────────────── +let profileBaselineJson = ''; +let stickyProfileJson = null; +let activeProfileStorageName = null; +let suppressProfileDirty = false; +let profileDirtyTimer = null; + initViewer(canvas); // Apply saved theme to 3D viewport on startup @@ -290,6 +314,7 @@ function populateLanguageSelector() { document.querySelectorAll('select[id="mapping-mode"] option[data-i18n-opt]').forEach(opt => { opt.textContent = t(opt.dataset.i18nOpt); }); + refreshProfileDropdown(); // Refresh dynamic count text to current language if (currentGeometry) refreshExclusionOverlay(); }); @@ -326,12 +351,14 @@ scaleVVal.value = posToScale(parseFloat(scaleVSlider.value)); loadPresets().then(presets => { PRESETS = presets; buildPresetGrid(); + initProfiles(); loadDefaultCube(); // Select Crystal as the default preset const noiseIdx = PRESETS.findIndex(p => p.name === 'Crystal'); const defaultIdx = noiseIdx !== -1 ? noiseIdx : 0; const swatches = presetGrid.querySelectorAll('.preset-swatch'); if (swatches[defaultIdx]) selectPreset(defaultIdx, swatches[defaultIdx]); + setProfileBaselineFromCurrent(); }).catch(err => console.error('Failed to load preset textures:', err)); // ── Preset grid ─────────────────────────────────────────────────────────────── @@ -548,6 +575,7 @@ function wireEvents() { if (precisionMaskingEnabled) deactivatePrecisionMasking(); canvas.style.cursor = exclusionTool ? 'crosshair' : ''; brushCursorEl.style.display = 'none'; + scheduleProfileDirtyCheck(); }); exclBrushRadiusBtn.addEventListener('click', () => { @@ -557,18 +585,21 @@ function wireEvents() { if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden'); if (exclusionTool === 'brush') precisionMaskingRow.classList.remove('hidden'); if (exclusionTool === 'brush') canvas.style.cursor = 'none'; + scheduleProfileDirtyCheck(); }); exclBrushRadiusSlider.addEventListener('input', () => { brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2; exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value); checkPrecisionOutdated(); + scheduleProfileDirtyCheck(); }); exclBrushRadiusSlider.addEventListener('dblclick', () => { exclBrushRadiusSlider.value = exclBrushRadiusSlider.defaultValue; brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2; exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value); checkPrecisionOutdated(); + scheduleProfileDirtyCheck(); }); exclBrushRadiusVal.addEventListener('change', () => { let diam = Math.max(0.2, Math.min(100, parseFloat(exclBrushRadiusVal.value) || 10)); @@ -576,24 +607,28 @@ function wireEvents() { exclBrushRadiusSlider.value = diam; exclBrushRadiusVal.value = diam; checkPrecisionOutdated(); + scheduleProfileDirtyCheck(); }); exclThresholdSlider.addEventListener('input', () => { bucketThreshold = parseFloat(exclThresholdSlider.value); exclThresholdVal.value = bucketThreshold; _lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes + scheduleProfileDirtyCheck(); }); exclThresholdSlider.addEventListener('dblclick', () => { exclThresholdSlider.value = exclThresholdSlider.defaultValue; bucketThreshold = parseFloat(exclThresholdSlider.value); exclThresholdVal.value = bucketThreshold; _lastHoverTriIdx = -1; + scheduleProfileDirtyCheck(); }); exclThresholdVal.addEventListener('change', () => { bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 20)); exclThresholdSlider.value = bucketThreshold; exclThresholdVal.value = bucketThreshold; _lastHoverTriIdx = -1; + scheduleProfileDirtyCheck(); }); exclClearBtn.addEventListener('click', () => { @@ -704,6 +739,7 @@ function wireEvents() { if (exclusionTool) setExclusionTool(null); licenseOverlay.classList.add('hidden'); imprintOverlay.classList.add('hidden'); + if (profileSaveOverlay) profileSaveOverlay.classList.add('hidden'); } }); } @@ -726,6 +762,7 @@ function setSelectionMode(include) { excludedFaces = new Set(); precisionExcludedFaces = new Set(); refreshExclusionOverlay(); + scheduleProfileDirtyCheck(); } function setExclusionTool(tool) { @@ -1261,6 +1298,332 @@ function formatM(n) { : String(n); } +// ── Profiles ───────────────────────────────────────────────────────────────── + +function collectExclusionUi() { + return { + selectionMode, + brushIsRadius, + brushDiameter: parseFloat(exclBrushRadiusSlider.value), + bucketThreshold: parseFloat(exclThresholdSlider.value), + }; +} + +function serializeProfileJsonString() { + return JSON.stringify(buildProfilePayload(settings, activeMapEntry, collectExclusionUi())); +} + +function scheduleProfileDirtyCheck() { + if (suppressProfileDirty) return; + clearTimeout(profileDirtyTimer); + profileDirtyTimer = setTimeout(updateProfileDirtyUI, 120); +} + +function updateProfileDirtyUI() { + if (!profileSaveBtn) return; + const dirty = serializeProfileJsonString() !== profileBaselineJson; + profileSaveBtn.classList.toggle('hidden', !dirty); +} + +function setProfileBaselineFromCurrent() { + profileBaselineJson = serializeProfileJsonString(); + updateProfileDirtyUI(); +} + +function discardPrecisionWithoutBake() { + if (precisionGeometry) { + precisionGeometry.dispose(); + precisionGeometry = null; + } + precisionParentMap = null; + precisionEdgeLength = null; + precisionCentroids = null; + precisionBoundRadii = null; + precisionAdjacency = null; + precisionMaskingEnabled = false; + precisionMaskingToggle.checked = false; + precisionExcludedFaces = new Set(); + precisionStatus.textContent = ''; + precisionOutdated.classList.add('hidden'); + precisionRefreshBtn.classList.add('hidden'); + precisionWarning.classList.add('hidden'); + precisionMaskingRow.classList.add('hidden'); + if (currentGeometry) { + setMeshGeometry(currentGeometry); + } +} + +function syncSettingsToDom() { + mappingSelect.value = String(settings.mappingMode); + capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none'; + + scaleUSlider.value = scaleToPos(settings.scaleU); + scaleUVal.value = settings.scaleU; + scaleVSlider.value = scaleToPos(settings.scaleV); + scaleVVal.value = settings.scaleV; + + lockScaleBtn.classList.toggle('active', settings.lockScale); + lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale)); + + offsetUSlider.value = settings.offsetU; + offsetUVal.value = settings.offsetU.toFixed(2); + offsetVSlider.value = settings.offsetV; + offsetVVal.value = settings.offsetV.toFixed(2); + + rotationSlider.value = settings.rotation; + rotationVal.value = Math.round(settings.rotation); + + amplitudeSlider.value = settings.amplitude; + amplitudeVal.value = settings.amplitude.toFixed(2); + + refineLenSlider.value = settings.refineLength; + refineLenVal.value = settings.refineLength.toFixed(2); + + maxTriSlider.value = settings.maxTriangles; + maxTriVal.textContent = formatM(settings.maxTriangles); + + seamBlendSlider.value = settings.mappingBlend; + seamBlendVal.value = settings.mappingBlend.toFixed(2); + + seamBandWidthSlider.value = settings.seamBandWidth; + seamBandWidthVal.value = settings.seamBandWidth.toFixed(2); + + textureSmoothingSlider.value = settings.textureSmoothing; + textureSmoothingVal.value = settings.textureSmoothing.toFixed(1); + + capAngleSlider.value = settings.capAngle; + capAngleVal.value = Math.round(settings.capAngle); + + bottomAngleLimitSlider.value = settings.bottomAngleLimit; + bottomAngleLimitVal.value = settings.bottomAngleLimit; + topAngleLimitSlider.value = settings.topAngleLimit; + topAngleLimitVal.value = settings.topAngleLimit; + + symmetricDispToggle.checked = settings.symmetricDisplacement; + dispPreviewToggle.checked = settings.useDisplacement; +} + +function applyExclusionUiPayload(ui) { + if (typeof ui.selectionMode === 'boolean' && ui.selectionMode !== selectionMode) { + setSelectionMode(ui.selectionMode); + } + brushIsRadius = ui.brushIsRadius ?? false; + exclBrushSingleBtn.classList.toggle('active', !brushIsRadius); + exclBrushRadiusBtn.classList.toggle('active', brushIsRadius); + const diameter = ui.brushDiameter ?? 10; + exclBrushRadiusSlider.value = diameter; + exclBrushRadiusVal.value = diameter; + brushRadius = diameter / 2; + const th = ui.bucketThreshold ?? 20; + exclThresholdSlider.value = th; + exclThresholdVal.value = th; + bucketThreshold = th; + exclBrushTypeRow.classList.toggle('hidden', exclusionTool !== 'brush'); + exclRadiusRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius)); + precisionMaskingRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius)); + exclThresholdRow.classList.toggle('hidden', exclusionTool !== 'bucket'); +} + +async function applyProfilePayload(payload) { + if (!validateProfile(payload)) { + throw new Error('Invalid profile'); + } + + suppressProfileDirty = true; + try { + discardPrecisionWithoutBake(); + await toggleDisplacementPreview(false); + + for (const k of Object.keys(settings)) { + if (k in payload.settings) settings[k] = payload.settings[k]; + } + + syncSettingsToDom(); + + const tex = payload.texture; + if (tex.kind === 'preset') { + const idx = PRESETS.findIndex(p => p.name === tex.name); + if (idx >= 0) { + document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); + const swatches = presetGrid.querySelectorAll('.preset-swatch'); + if (swatches[idx]) swatches[idx].classList.add('active'); + activeMapEntry = PRESETS[idx]; + activeMapName.textContent = PRESETS[idx].name; + } else { + alert(t('profile.presetNotFound', { name: tex.name })); + } + } else if (tex.kind === 'custom') { + document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); + activeMapEntry = await loadTextureFromDataUrl(tex.dataUrl, tex.fileName || 'custom.png'); + activeMapEntry.isCustom = true; + activeMapName.textContent = activeMapEntry.name; + } else { + activeMapEntry = null; + activeMapName.textContent = t('ui.noMapSelected'); + } + + applyExclusionUiPayload(payload.exclusionUi); + + excludedFaces = new Set(); + precisionExcludedFaces = new Set(); + setExclusionTool(null); + + if (!currentGeometry && settings.useDisplacement) { + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + } else if (settings.useDisplacement && currentGeometry) { + await toggleDisplacementPreview(true); + } else { + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + } + + checkAmplitudeWarning(); + exportBtn.disabled = (activeMapEntry === null); + updatePreview(); + refreshExclusionOverlay(); + } finally { + suppressProfileDirty = false; + } + + profileBaselineJson = serializeProfileJsonString(); + updateProfileDirtyUI(); +} + +async function maybeApplyStickyProfile() { + if (!stickyProfileJson) return; + try { + await applyProfilePayload(JSON.parse(stickyProfileJson)); + } catch (e) { + console.error(e); + } +} + +function refreshProfileDropdown() { + if (!profileSelect) return; + const names = listProfileNames(); + const prev = profileSelect.value; + profileSelect.innerHTML = ''; + const opt0 = document.createElement('option'); + opt0.value = ''; + opt0.textContent = t('profile.optionNone'); + profileSelect.appendChild(opt0); + for (const name of names) { + const o = document.createElement('option'); + o.value = name; + o.textContent = name; + profileSelect.appendChild(o); + } + if (names.includes(prev)) profileSelect.value = prev; + else if (activeProfileStorageName && names.includes(activeProfileStorageName)) { + profileSelect.value = activeProfileStorageName; + } +} + +function openProfileSaveModal() { + if (!profileSaveOverlay) return; + profileSaveName.value = activeProfileStorageName || ''; + profileSaveOverlay.classList.remove('hidden'); + profileSaveName.focus(); + profileSaveName.select(); +} + +function closeProfileSaveModal() { + if (profileSaveOverlay) profileSaveOverlay.classList.add('hidden'); +} + +function initProfiles() { + if (!profileSelect) return; + + refreshProfileDropdown(); + + profileSelect.addEventListener('change', async () => { + const name = profileSelect.value; + if (!name) { + stickyProfileJson = null; + activeProfileStorageName = null; + setProfileBaselineFromCurrent(); + return; + } + const map = loadProfilesMap(); + const payload = map[name]; + if (!payload) return; + try { + await applyProfilePayload(payload); + activeProfileStorageName = name; + stickyProfileJson = JSON.stringify(payload); + } catch (e) { + console.error(e); + alert(t('profile.applyFailed', { msg: e.message })); + } + }); + + profileSaveBtn.addEventListener('click', () => openProfileSaveModal()); + + profileSaveConfirm.addEventListener('click', () => { + const name = profileSaveName.value.trim(); + if (!name) { + alert(t('profile.nameRequired')); + return; + } + const payload = buildProfilePayload(settings, activeMapEntry, collectExclusionUi()); + if (payload.texture.kind === 'none') { + alert(t('profile.exportNeedsMap')); + return; + } + saveNamedProfile(name, payload); + refreshProfileDropdown(); + profileSelect.value = name; + activeProfileStorageName = name; + stickyProfileJson = JSON.stringify(payload); + setProfileBaselineFromCurrent(); + closeProfileSaveModal(); + }); + + profileSaveCancel.addEventListener('click', closeProfileSaveModal); + profileSaveClose.addEventListener('click', closeProfileSaveModal); + profileSaveOverlay.addEventListener('click', (e) => { + if (e.target === profileSaveOverlay) closeProfileSaveModal(); + }); + + profileSaveName.addEventListener('keydown', (e) => { + if (e.key === 'Enter') profileSaveConfirm.click(); + }); + + profileExportBtn.addEventListener('click', () => { + const payload = buildProfilePayload(settings, activeMapEntry, collectExclusionUi()); + if (payload.texture.kind === 'none') { + alert(t('profile.exportNeedsMap')); + return; + } + const safeName = (activeProfileStorageName || 'bumpmesh-profile').replace(/[^\w\-]+/g, '_'); + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${safeName}.bumpmesh.json`; + a.click(); + URL.revokeObjectURL(a.href); + }); + + profileImportInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + e.target.value = ''; + if (!file) return; + try { + const text = await file.text(); + const obj = JSON.parse(text); + if (!validateProfile(obj)) throw new Error('invalid'); + await applyProfilePayload(obj); + activeProfileStorageName = null; + profileSelect.value = ''; + stickyProfileJson = JSON.stringify(obj); + } catch (err) { + console.error(err); + alert(t('profile.invalidFile')); + } + }); +} + // ── STL loading ─────────────────────────────────────────────────────────────── function loadDefaultCube() { @@ -1425,6 +1788,8 @@ async function handleModelFile(file) { exportBtn.disabled = (activeMapEntry === null); updatePreview(); + + await maybeApplyStickyProfile(); } catch (err) { console.error('Failed to load model:', err); alert(t('alerts.loadFailed', { msg: err.message })); @@ -1650,50 +2015,54 @@ function getEffectiveMapEntry() { } function updatePreview() { - if (!currentGeometry || !currentBounds) return; - - // Texture aspect correction so non-square textures keep their proportions. - // A 512×279 texture needs aspectV = 512/279 ≈ 1.84 so V tiles faster (more - // repetitions), making each tile shorter in world-space to match the texture's - // wider-than-tall content. The wider axis gets aspect = 1 (unchanged). - const tw = activeMapEntry?.width ?? 1, th = activeMapEntry?.height ?? 1; - const tmax = Math.max(tw, th, 1); - const fullSettings = { - ...settings, - bounds: currentBounds, - textureAspectU: tmax / Math.max(tw, 1), - textureAspectV: tmax / Math.max(th, 1), - }; + try { + if (!currentGeometry || !currentBounds) return; + + // Texture aspect correction so non-square textures keep their proportions. + // A 512×279 texture needs aspectV = 512/279 ≈ 1.84 so V tiles faster (more + // repetitions), making each tile shorter in world-space to match the texture's + // wider-than-tall content. The wider axis gets aspect = 1 (unchanged). + const tw = activeMapEntry?.width ?? 1, th = activeMapEntry?.height ?? 1; + const tmax = Math.max(tw, th, 1); + const fullSettings = { + ...settings, + bounds: currentBounds, + textureAspectU: tmax / Math.max(tw, 1), + textureAspectV: tmax / Math.max(th, 1), + }; - if (!activeMapEntry) { - // No map yet — plain material - if (previewMaterial) { - setMeshMaterial(null); - previewMaterial.dispose(); - previewMaterial = null; + if (!activeMapEntry) { + // No map yet — plain material + if (previewMaterial) { + setMeshMaterial(null); + previewMaterial.dispose(); + previewMaterial = null; + } + exportBtn.disabled = true; + return; } - exportBtn.disabled = true; - return; - } - // Choose geometry: subdivided preview (with smoothNormal attribute) or original - const activeGeo = (settings.useDisplacement && dispPreviewGeometry) - ? dispPreviewGeometry - : currentGeometry; + // Choose geometry: subdivided preview (with smoothNormal attribute) or original + const activeGeo = (settings.useDisplacement && dispPreviewGeometry) + ? dispPreviewGeometry + : currentGeometry; - // Ensure faceMask attribute is current before rendering - updateFaceMask(activeGeo); + // Ensure faceMask attribute is current before rendering + updateFaceMask(activeGeo); - const effectiveEntry = getEffectiveMapEntry(); + const effectiveEntry = getEffectiveMapEntry(); - if (!previewMaterial) { - previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings); - loadGeometry(activeGeo, previewMaterial); - } else { - updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings); - } + if (!previewMaterial) { + previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings); + loadGeometry(activeGeo, previewMaterial); + } else { + updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings); + } - exportBtn.disabled = false; + exportBtn.disabled = false; + } finally { + scheduleProfileDirtyCheck(); + } } // ── Displacement preview ────────────────────────────────────────────────────── diff --git a/js/presetTextures.js b/js/presetTextures.js index 7ad1295..bce09e0 100644 --- a/js/presetTextures.js +++ b/js/presetTextures.js @@ -107,3 +107,33 @@ export function loadCustomTexture(file) { img.src = url; }); } + +/** + * Restore a displacement map from a PNG data URL (e.g. from an exported profile). + */ +export function loadTextureFromDataUrl(dataUrl, name = 'custom.png') { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const { w, h } = fitDimensions(img.width, img.height); + const canvas = makeCanvas(w, h); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, w, h); + const imageData = ctx.getImageData(0, 0, w, h); + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.name = name; + resolve({ + name, + fullCanvas: canvas, + texture, + imageData, + width: w, + height: h, + isCustom: true, + }); + }; + img.onerror = () => reject(new Error('Failed to decode profile image')); + img.src = dataUrl; + }); +} diff --git a/js/profiles.js b/js/profiles.js new file mode 100644 index 0000000..5ef2a9e --- /dev/null +++ b/js/profiles.js @@ -0,0 +1,72 @@ +/** + * BumpMesh profile import/export (JSON) and browser-local named profiles. + */ + +export const PROFILE_VERSION = 1; +export const PROFILE_SCHEMA = 'BumpMeshProfile'; + +const STORAGE_KEY = 'stlt-bumpmesh-profiles-v1'; + +export function loadProfilesMap() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const o = JSON.parse(raw); + return typeof o === 'object' && o !== null && !Array.isArray(o) ? o : {}; + } catch { + return {}; + } +} + +export function saveProfilesMap(map) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); +} + +export function saveNamedProfile(name, profile) { + const map = loadProfilesMap(); + map[name] = profile; + saveProfilesMap(map); +} + +export function deleteNamedProfile(name) { + const map = loadProfilesMap(); + delete map[name]; + saveProfilesMap(map); +} + +export function listProfileNames() { + return Object.keys(loadProfilesMap()).sort((a, b) => a.localeCompare(b)); +} + +/** @param activeMapEntry - same shape as main.js `activeMapEntry` (preset or custom map) */ +export function captureTexture(activeMapEntry) { + if (!activeMapEntry) return { kind: 'none' }; + if (activeMapEntry.isCustom) { + return { + kind: 'custom', + fileName: activeMapEntry.name || 'custom.png', + dataUrl: activeMapEntry.fullCanvas.toDataURL('image/png'), + }; + } + return { kind: 'preset', name: activeMapEntry.name }; +} + +export function buildProfilePayload(settings, activeMapEntry, exclusionUi) { + return { + $schema: PROFILE_SCHEMA, + version: PROFILE_VERSION, + settings: { ...settings }, + texture: captureTexture(activeMapEntry), + exclusionUi: { ...exclusionUi }, + }; +} + +export function validateProfile(obj) { + if (!obj || typeof obj !== 'object') return false; + if (obj.$schema !== PROFILE_SCHEMA) return false; + if (obj.version !== PROFILE_VERSION) return false; + if (!obj.settings || typeof obj.settings !== 'object') return false; + if (!obj.texture || typeof obj.texture !== 'object') return false; + if (!obj.exclusionUi || typeof obj.exclusionUi !== 'object') return false; + return true; +} diff --git a/style.css b/style.css index 9285f91..f992b9e 100644 --- a/style.css +++ b/style.css @@ -362,6 +362,187 @@ main { align-items: stretch; } +/* ── Profile toolbar (Orca-style row) ───────────────────────────────── */ +.profile-section { + padding-top: 12px; + padding-bottom: 12px; +} + +.profile-toolbar { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.profile-select-wrap { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.profile-toolbar-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.profile-select { + width: 100%; + height: 32px; + padding: 0 28px 0 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; + font-family: inherit; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath fill='%2366667a' d='M0 0l4 4 4-4H0z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: border-color 0.15s, color 0.15s; +} + +.profile-select:hover, +.profile-select:focus { + border-color: var(--accent); + outline: none; +} + +.profile-save-btn { + flex-shrink: 0; + height: 32px; + padding: 0 14px; + background: var(--accent); + border: none; + border-radius: var(--radius); + color: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.profile-save-btn:hover { + background: var(--accent-hover); +} + +.profile-save-btn.hidden { + display: none; +} + +.profile-io { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.profile-io-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.profile-io-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.profile-io-label { + margin: 0; +} + +.profile-save-modal .profile-save-hint { + font-size: 12px; + color: var(--text-muted); + margin: 8px 0 14px; + line-height: 1.45; +} + +.profile-save-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.profile-save-field label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); +} + +.profile-save-name-input { + width: 100%; + height: 36px; + padding: 0 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: inherit; +} + +.profile-save-name-input:focus { + outline: none; + border-color: var(--accent); +} + +.profile-save-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.profile-save-cancel-btn { + height: 34px; + padding: 0 14px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; + font-family: inherit; + cursor: pointer; +} + +.profile-save-confirm-btn { + height: 34px; + padding: 0 16px; + background: var(--accent); + border: none; + border-radius: var(--radius); + color: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; +} + +.profile-save-confirm-btn:hover { + background: var(--accent-hover); +} + /* ── Settings panel ──────────────────────────────────────────────────── */ #settings-panel { width: var(--sidebar-w);
Enter a name. Saving overwrites an existing profile with the same name.