feat: add texture smoothing feature and update translations for UI consistency

This commit is contained in:
CNCKitchen
2026-03-31 17:25:24 +02:00
parent 668024f5c4
commit 88ab1747e7
3 changed files with 68 additions and 12 deletions
+6 -1
View File
@@ -121,6 +121,11 @@
</label> </label>
<input type="file" id="texture-file-input" accept="image/*" hidden /> <input type="file" id="texture-file-input" accept="image/*" hidden />
<div id="active-map-name" class="active-map-name" data-i18n="ui.noMapSelected">No map selected</div> <div id="active-map-name" class="active-map-name" data-i18n="ui.noMapSelected">No map selected</div>
<div class="form-row slider-row">
<label for="texture-smoothing" data-i18n="labels.textureSmoothing" data-i18n-title="tooltips.textureSmoothing" title="Applies a Gaussian blur to the displacement map. Higher values produce softer, more gradual surface detail. 0 = off.">Texture Smoothing ⓘ</label>
<input type="range" id="texture-smoothing" min="0" max="20" step="any" value="0" />
<input type="number" class="val" id="texture-smoothing-val" value="0" min="0" max="20" step="any" />
</div>
</section> </section>
<!-- Projection --> <!-- Projection -->
@@ -144,7 +149,7 @@
<input type="number" class="val" id="seam-blend-val" value="1" min="0" max="1" step="0.01" /> <input type="number" class="val" id="seam-blend-val" value="1" min="0" max="1" step="0.01" />
</div> </div>
<div class="form-row slider-row"> <div class="form-row slider-row">
<label for="seam-band-width" data-i18n="labels.smoothing" data-i18n-title="tooltips.smoothing" title="Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.">Smoothing ⓘ</label> <label for="seam-band-width" data-i18n="labels.transitionSmoothing" data-i18n-title="tooltips.transitionSmoothing" title="Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.">Transition Smoothing ⓘ</label>
<input type="range" id="seam-band-width" min="0" max="1" step="0.01" value="0.5" /> <input type="range" id="seam-band-width" min="0" max="1" step="0.01" value="0.5" />
<input type="number" class="val" id="seam-band-width-val" value="0.5" min="0" max="1" step="0.01" /> <input type="number" class="val" id="seam-band-width-val" value="0.5" min="0" max="1" step="0.01" />
</div> </div>
+8 -4
View File
@@ -53,8 +53,10 @@ export const TRANSLATIONS = {
// Seam blend // Seam blend
'labels.seamBlend': 'Seam Blend \u24d8', 'labels.seamBlend': 'Seam Blend \u24d8',
'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.', 'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.',
'labels.smoothing': 'Smoothing \u24d8', 'labels.transitionSmoothing': 'Transition 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.', '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', '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.', '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 // Seam blend
'labels.seamBlend': 'Nahtglättung \u24d8', 'labels.seamBlend': 'Nahtglättung \u24d8',
'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.', 'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.',
'labels.smoothing': 'Glättung \u24d8', 'labels.transitionSmoothing': 'Übergangsglä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.', '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', '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.', 'tooltips.capAngle': 'Winkel (in Grad) ab dem die Deckel-/Bodenprojektion einsetzt. Kleinere Werte beschränken die Deckelprojektion auf nahezu flache Flächen.',
+54 -7
View File
@@ -53,6 +53,7 @@ const settings = {
topAngleLimit: 0, topAngleLimit: 0,
mappingBlend: 1, mappingBlend: 1,
seamBandWidth: 0.5, seamBandWidth: 0.5,
textureSmoothing: 0,
capAngle: 20, capAngle: 20,
symmetricDisplacement: false, symmetricDisplacement: false,
useDisplacement: false, useDisplacement: false,
@@ -112,6 +113,8 @@ const seamBlendSlider = document.getElementById('seam-blend');
const seamBlendVal = document.getElementById('seam-blend-val'); const seamBlendVal = document.getElementById('seam-blend-val');
const seamBandWidthSlider = document.getElementById('seam-band-width'); const seamBandWidthSlider = document.getElementById('seam-band-width');
const seamBandWidthVal = document.getElementById('seam-band-width-val'); 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 capAngleSlider = document.getElementById('cap-angle');
const capAngleVal = document.getElementById('cap-angle-val'); const capAngleVal = document.getElementById('cap-angle-val');
const capAngleRow = document.getElementById('cap-angle-row'); 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) { function selectPreset(idx, swatchEl) {
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
swatchEl.classList.add('active'); swatchEl.classList.add('active');
activeMapEntry = PRESETS[idx]; activeMapEntry = PRESETS[idx];
activeMapName.textContent = PRESETS[idx].name; activeMapName.textContent = PRESETS[idx].name;
resetTextureSmoothing();
updatePreview(); updatePreview();
} }
@@ -274,6 +284,7 @@ function wireEvents() {
activeMapEntry.isCustom = true; activeMapEntry.isCustom = true;
activeMapName.textContent = file.name; activeMapName.textContent = file.name;
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
resetTextureSmoothing();
updatePreview(); updatePreview();
} catch (err) { } catch (err) {
console.error('Failed to load texture:', err); console.error('Failed to load texture:', err);
@@ -337,6 +348,7 @@ function wireEvents() {
linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; }); linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; });
linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); }); linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); });
linkSlider(seamBandWidthSlider, seamBandWidthVal, v => { settings.seamBandWidth = 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); }); linkSlider(capAngleSlider, capAngleVal, v => { settings.capAngle = v; return Math.round(v); });
symmetricDispToggle.addEventListener('change', () => { symmetricDispToggle.addEventListener('change', () => {
settings.symmetricDisplacement = symmetricDispToggle.checked; settings.symmetricDisplacement = symmetricDispToggle.checked;
@@ -1299,6 +1311,38 @@ function buildParentFaceMap(subdivGeo) {
return parentMap; 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() { function updatePreview() {
if (!currentGeometry || !currentBounds) return; if (!currentGeometry || !currentBounds) return;
@@ -1323,11 +1367,13 @@ function updatePreview() {
// Ensure faceMask attribute is current before rendering // Ensure faceMask attribute is current before rendering
updateFaceMask(activeGeo); updateFaceMask(activeGeo);
const effectiveEntry = getEffectiveMapEntry();
if (!previewMaterial) { if (!previewMaterial) {
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); previewMaterial = createPreviewMaterial(effectiveEntry.texture, fullSettings);
loadGeometry(activeGeo, previewMaterial); loadGeometry(activeGeo, previewMaterial);
} else { } else {
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings); updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings);
} }
exportBtn.disabled = false; exportBtn.disabled = false;
@@ -1447,7 +1493,7 @@ async function toggleDisplacementPreview(enable) {
if (!enable) { if (!enable) {
// Revert to original geometry with bump-only shading. // Revert to original geometry with bump-only shading.
if (currentGeometry && previewMaterial) { if (currentGeometry && previewMaterial) {
updateMaterial(previewMaterial, activeMapEntry?.texture, { ...settings, bounds: currentBounds }); updateMaterial(previewMaterial, getEffectiveMapEntry()?.texture, { ...settings, bounds: currentBounds });
updateFaceMask(currentGeometry); updateFaceMask(currentGeometry);
setMeshGeometry(currentGeometry); setMeshGeometry(currentGeometry);
} }
@@ -1499,7 +1545,7 @@ async function toggleDisplacementPreview(enable) {
previewMaterial = null; previewMaterial = null;
} }
const fullSettings = { ...settings, bounds: currentBounds }; const fullSettings = { ...settings, bounds: currentBounds };
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); previewMaterial = createPreviewMaterial(getEffectiveMapEntry().texture, fullSettings);
setMeshGeometry(dispPreviewGeometry); setMeshGeometry(dispPreviewGeometry);
setMeshMaterial(previewMaterial); setMeshMaterial(previewMaterial);
@@ -1585,12 +1631,13 @@ async function handleExport() {
const subTriCount = subdivided.attributes.position.count / 3; const subTriCount = subdivided.attributes.position.count / 3;
setProgress(0.38, t('progress.applyingDisplacement', { n: subTriCount.toLocaleString() })); setProgress(0.38, t('progress.applyingDisplacement', { n: subTriCount.toLocaleString() }));
const exportEntry = getEffectiveMapEntry();
const displaced = await runAsync(() => const displaced = await runAsync(() =>
applyDisplacement( applyDisplacement(
subdivided, subdivided,
activeMapEntry.imageData, exportEntry.imageData,
activeMapEntry.width, exportEntry.width,
activeMapEntry.height, exportEntry.height,
settings, settings,
currentBounds, currentBounds,
(p) => setProgress(0.38 + p * 0.32, t('progress.displacingVertices')) (p) => setProgress(0.38 + p * 0.32, t('progress.displacingVertices'))