diff --git a/index.html b/index.html index 28e4cbf..4b57bc9 100644 --- a/index.html +++ b/index.html @@ -121,6 +121,11 @@
No map selected
+
+ + + +
@@ -144,7 +149,7 @@
- +
diff --git a/js/i18n.js b/js/i18n.js index a7a2184..102e8d0 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -53,8 +53,10 @@ export const TRANSLATIONS = { // Seam blend 'labels.seamBlend': 'Seam Blend \u24d8', 'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.', - 'labels.smoothing': 'Smoothing \u24d8', - 'tooltips.smoothing': 'Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.', + 'labels.transitionSmoothing': 'Transition Smoothing \u24d8', + 'tooltips.transitionSmoothing': 'Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.', + 'labels.textureSmoothing': 'Texture Smoothing \u24d8', + 'tooltips.textureSmoothing': 'Applies a Gaussian blur to the displacement map. Higher values produce softer, more gradual surface detail. 0 = off.', 'labels.capAngle': 'Cap Angle \u24d8', 'tooltips.capAngle': 'Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.', @@ -209,8 +211,10 @@ export const TRANSLATIONS = { // Seam blend 'labels.seamBlend': 'Nahtglättung \u24d8', 'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.', - 'labels.smoothing': 'Glättung \u24d8', - 'tooltips.smoothing': 'Breite der Übergangszone an Nahtkanten. Niedrige Werte halten den Übergang nah an der Naht; höhere Werte glätten einen breiteren Bereich.', + 'labels.transitionSmoothing': 'Übergangsglättung \u24d8', + 'tooltips.transitionSmoothing': 'Breite der Übergangszone an Nahtkanten. Niedrige Werte halten den Übergang nah an der Naht; höhere Werte glätten einen breiteren Bereich.', + 'labels.textureSmoothing': 'Texturglättung \u24d8', + 'tooltips.textureSmoothing': 'Wendet einen Gaußschen Weichzeichner auf die Verschiebungskarte an. Höhere Werte erzeugen weichere, fließendere Oberflächendetails. 0 = aus.', 'labels.capAngle': 'Übergangswinkel \u24d8', 'tooltips.capAngle': 'Winkel (in Grad) ab dem die Deckel-/Bodenprojektion einsetzt. Kleinere Werte beschränken die Deckelprojektion auf nahezu flache Flächen.', diff --git a/js/main.js b/js/main.js index 12492d3..eee63ae 100644 --- a/js/main.js +++ b/js/main.js @@ -53,6 +53,7 @@ const settings = { topAngleLimit: 0, mappingBlend: 1, seamBandWidth: 0.5, + textureSmoothing: 0, capAngle: 20, symmetricDisplacement: false, useDisplacement: false, @@ -112,6 +113,8 @@ const seamBlendSlider = document.getElementById('seam-blend'); const seamBlendVal = document.getElementById('seam-blend-val'); const seamBandWidthSlider = document.getElementById('seam-band-width'); const seamBandWidthVal = document.getElementById('seam-band-width-val'); +const textureSmoothingSlider = document.getElementById('texture-smoothing'); +const textureSmoothingVal = document.getElementById('texture-smoothing-val'); const capAngleSlider = document.getElementById('cap-angle'); const capAngleVal = document.getElementById('cap-angle-val'); const capAngleRow = document.getElementById('cap-angle-row'); @@ -215,11 +218,18 @@ function buildPresetGrid() { }); } +function resetTextureSmoothing() { + settings.textureSmoothing = 0; + textureSmoothingSlider.value = 0; + textureSmoothingVal.value = 0; +} + function selectPreset(idx, swatchEl) { document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); swatchEl.classList.add('active'); activeMapEntry = PRESETS[idx]; activeMapName.textContent = PRESETS[idx].name; + resetTextureSmoothing(); updatePreview(); } @@ -274,6 +284,7 @@ function wireEvents() { activeMapEntry.isCustom = true; activeMapName.textContent = file.name; document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); + resetTextureSmoothing(); updatePreview(); } catch (err) { console.error('Failed to load texture:', err); @@ -337,6 +348,7 @@ function wireEvents() { linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; }); linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); }); linkSlider(seamBandWidthSlider, seamBandWidthVal, v => { settings.seamBandWidth = v; return v.toFixed(2); }); + linkSlider(textureSmoothingSlider, textureSmoothingVal, v => { settings.textureSmoothing = v; return v.toFixed(1); }); linkSlider(capAngleSlider, capAngleVal, v => { settings.capAngle = v; return Math.round(v); }); symmetricDispToggle.addEventListener('change', () => { settings.symmetricDisplacement = symmetricDispToggle.checked; @@ -1299,6 +1311,38 @@ function buildParentFaceMap(subdivGeo) { return parentMap; } +function getEffectiveMapEntry() { + if (!activeMapEntry || settings.textureSmoothing === 0) return activeMapEntry; + const { fullCanvas, width, height } = activeMapEntry; + // Tile the source 3×3 before blurring so edge pixels have correct + // neighbours and the blurred centre tile is seamlessly tileable. + const tiled = document.createElement('canvas'); + tiled.width = width * 3; + tiled.height = height * 3; + const tc = tiled.getContext('2d'); + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + tc.drawImage(fullCanvas, col * width, row * height); + } + } + // Blur the 3×3 canvas, then crop out only the centre tile. + const blurred = document.createElement('canvas'); + blurred.width = width * 3; + blurred.height = height * 3; + const bc = blurred.getContext('2d'); + bc.filter = `blur(${settings.textureSmoothing}px)`; + bc.drawImage(tiled, 0, 0); + bc.filter = 'none'; + const offscreen = document.createElement('canvas'); + offscreen.width = width; + offscreen.height = height; + offscreen.getContext('2d').drawImage(blurred, width, height, width, height, 0, 0, width, height); + const imageData = offscreen.getContext('2d').getImageData(0, 0, width, height); + const texture = new THREE.CanvasTexture(offscreen); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + return { ...activeMapEntry, imageData, texture }; +} + function updatePreview() { if (!currentGeometry || !currentBounds) return; @@ -1323,11 +1367,13 @@ function updatePreview() { // Ensure faceMask attribute is current before rendering updateFaceMask(activeGeo); + const effectiveEntry = getEffectiveMapEntry(); + if (!previewMaterial) { - previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); + previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings); loadGeometry(activeGeo, previewMaterial); } else { - updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings); + updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings); } exportBtn.disabled = false; @@ -1447,7 +1493,7 @@ async function toggleDisplacementPreview(enable) { if (!enable) { // Revert to original geometry with bump-only shading. if (currentGeometry && previewMaterial) { - updateMaterial(previewMaterial, activeMapEntry?.texture, { ...settings, bounds: currentBounds }); + updateMaterial(previewMaterial, getEffectiveMapEntry()?.texture, { ...settings, bounds: currentBounds }); updateFaceMask(currentGeometry); setMeshGeometry(currentGeometry); } @@ -1499,7 +1545,7 @@ async function toggleDisplacementPreview(enable) { previewMaterial = null; } const fullSettings = { ...settings, bounds: currentBounds }; - previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); + previewMaterial = createPreviewMaterial(getEffectiveMapEntry().texture, fullSettings); setMeshGeometry(dispPreviewGeometry); setMeshMaterial(previewMaterial); @@ -1585,12 +1631,13 @@ async function handleExport() { const subTriCount = subdivided.attributes.position.count / 3; setProgress(0.38, t('progress.applyingDisplacement', { n: subTriCount.toLocaleString() })); + const exportEntry = getEffectiveMapEntry(); const displaced = await runAsync(() => applyDisplacement( subdivided, - activeMapEntry.imageData, - activeMapEntry.width, - activeMapEntry.height, + exportEntry.imageData, + exportEntry.width, + exportEntry.height, settings, currentBounds, (p) => setProgress(0.38 + p * 0.32, t('progress.displacingVertices'))