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'))