From a41b500bf288b9aeb674eaed5c8feefd533ad199 Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Sat, 28 Mar 2026 16:00:17 +0100 Subject: [PATCH] feat: add cap angle control for cylindrical mapping and update UV calculations --- index.html | 5 +++ js/main.js | 7 ++++ js/mapping.js | 88 ++++++++++++++++++++++++++++++++----------- js/previewMaterial.js | 45 +++++++++++++++++++--- 4 files changed, 118 insertions(+), 27 deletions(-) diff --git a/index.html b/index.html index 8a6d130..ffee765 100644 --- a/index.html +++ b/index.html @@ -147,6 +147,11 @@ + diff --git a/js/main.js b/js/main.js index e27e56f..f9f2916 100644 --- a/js/main.js +++ b/js/main.js @@ -53,6 +53,7 @@ const settings = { topAngleLimit: 0, mappingBlend: 1, seamBandWidth: 0.5, + capAngle: 20, symmetricDisplacement: false, useDisplacement: false, }; @@ -111,6 +112,9 @@ 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 capAngleSlider = document.getElementById('cap-angle'); +const capAngleVal = document.getElementById('cap-angle-val'); +const capAngleRow = document.getElementById('cap-angle-row'); const symmetricDispToggle = document.getElementById('symmetric-displacement'); const dispPreviewToggle = document.getElementById('displacement-preview'); @@ -279,6 +283,7 @@ function wireEvents() { // ── Settings ── mappingSelect.addEventListener('change', () => { settings.mappingMode = parseInt(mappingSelect.value, 10); + capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none'; updatePreview(); }); @@ -332,6 +337,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(capAngleSlider, capAngleVal, v => { settings.capAngle = v; return Math.round(v); }); symmetricDispToggle.addEventListener('change', () => { settings.symmetricDisplacement = symmetricDispToggle.checked; updatePreview(); @@ -1046,6 +1052,7 @@ async function handleSTL(file) { if (swatches.length > 0) swatches[0].classList.add('active'); } mappingSelect.value = String(settings.mappingMode); + capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none'; // Show mesh with a default material until a map is selected loadGeometry(geometry); diff --git a/js/mapping.js b/js/mapping.js index c5d9b9a..3bc1ed7 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -133,41 +133,66 @@ export function computeUV(pos, normal, mode, settings, bounds) { case MODE_CYLINDRICAL: { // mappingBlend=0 → pure side projection for all faces (original behaviour, no cap seam). - // mappingBlend>0 → smooth side↔cap blend; zone half-width = blend*0.20. + // mappingBlend>0 → smooth side↔cap blend. const r = Math.max(size.x, size.y) * 0.5; const C = TWO_PI * Math.max(r, 1e-6); const rx = pos.x - center.x; const ry = pos.y - center.y; const blend = settings.mappingBlend ?? 0.0; const theta = Math.atan2(ry, rx); - const uSide = (theta / TWO_PI) + 0.5; + const uRaw = (theta / TWO_PI) + 0.5; const vSide = (pos.z - min.z) / C; + + // Seam smoothing: cross-fade between left-side and right-side texture + // continuations at the atan2 wrap. Both sides use smoothly varying UVs + // (shifted by ±1.0 in raw space), preserving full texture detail. + const seamBand = (settings.seamBandWidth ?? 0.5) * 0.1; + const seamDist = Math.min(uRaw, 1.0 - uRaw); + const inSeamZone = seamBand > 0.001 && seamDist < seamBand; + + let sideSamples; + if (inSeamZone) { + const d = uRaw < 0.5 ? uRaw : uRaw - 1.0; + const tRaw = (d + seamBand) / (2.0 * seamBand); + const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep + const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + const tRight = applyTransform(d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + sideSamples = [ + { u: tRight.u, v: tRight.v, w: t }, + { u: tLeft.u, v: tLeft.v, w: 1 - t }, + ]; + } else { + const tSide = applyTransform(uRaw, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + sideSamples = [{ u: tSide.u, v: tSide.v, w: 1 }]; + } + if (blend <= 0.001) { - return applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + if (sideSamples.length === 1 && sideSamples[0].w === 1) return sideSamples[0]; + return { triplanar: true, samples: sideSamples }; } - const blendHalf = blend * 0.20; + + const capThreshold = Math.cos((settings.capAngle ?? 20) * Math.PI / 180); + const blendHalf = (settings.seamBandWidth ?? 0.5) * 0.5; const absnz = Math.abs(normal.z); - const capW = Math.max(0, Math.min(1, (absnz - (0.7 - blendHalf)) / (2 * blendHalf + 1e-6))); + const capW = Math.max(0, Math.min(1, (absnz - (capThreshold - blendHalf)) / (2 * blendHalf + 1e-6))); + if (capW <= 0) { - return applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + if (sideSamples.length === 1 && sideSamples[0].w === 1) return sideSamples[0]; + return { triplanar: true, samples: sideSamples }; } + const uCap = rx / C + 0.5; const vCap = ry / C + 0.5; + const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); + if (capW >= 1) { - return applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); + return tCap; } - // Return two separate samples so displacement.js blends the *heights*, - // not the UV coordinates (blending atan2-based and planar UVs directly - // produces garbage values in the transition zone). - const tSide = applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); - const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); - return { - triplanar: true, - samples: [ - { u: tSide.u, v: tSide.v, w: 1 - capW }, - { u: tCap.u, v: tCap.v, w: capW }, - ], - }; + + // Combine seam-blended side samples with cap sample + const samples = sideSamples.map(s => ({ u: s.u, v: s.v, w: s.w * (1 - capW) })); + samples.push({ u: tCap.u, v: tCap.v, w: capW }); + return { triplanar: true, samples }; } case MODE_SPHERICAL: { @@ -177,8 +202,29 @@ export function computeUV(pos, normal, mode, settings, bounds) { const r = Math.sqrt(rx*rx + ry*ry + rz*rz); const phi = Math.acos(Math.max(-1, Math.min(1, rz / Math.max(r, 1e-6)))); // [0, PI], Z is up const theta = Math.atan2(ry, rx); // [-PI, PI] - u = (theta / TWO_PI) + 0.5; - v = phi / Math.PI; + const uRaw = (theta / TWO_PI) + 0.5; + const vRaw = phi / Math.PI; + + // Seam smoothing: cross-fade at the atan2 wrap + const seamBand = (settings.seamBandWidth ?? 0.5) * 0.1; + const seamDist = Math.min(uRaw, 1.0 - uRaw); + if (seamBand > 0.001 && seamDist < seamBand) { + const d = uRaw < 0.5 ? uRaw : uRaw - 1.0; + const tRaw = (d + seamBand) / (2.0 * seamBand); + const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep + const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); + const tRight = applyTransform(d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); + return { + triplanar: true, + samples: [ + { u: tRight.u, v: tRight.v, w: t }, + { u: tLeft.u, v: tLeft.v, w: 1 - t }, + ], + }; + } + + u = uRaw; + v = vRaw; break; } diff --git a/js/previewMaterial.js b/js/previewMaterial.js index a513a80..096860c 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -35,6 +35,7 @@ const sharedGLSL = /* glsl */` uniform float topAngleLimit; uniform float mappingBlend; uniform float seamBandWidth; + uniform float capAngle; uniform int symmetricDisplacement; uniform int useDisplacement; @@ -110,19 +111,49 @@ const sharedGLSL = /* glsl */` } else if (mappingMode == 3) { float r = max(boundsSize.x, boundsSize.y) * 0.5; float C = TWO_PI * max(r, 1e-4); - float hSide = sampleMap(vec2(atan(rel.y, rel.x) / TWO_PI + 0.5, - (pos.z - boundsMin.z) / C)); + float u_cyl = atan(rel.y, rel.x) / TWO_PI + 0.5; + float v_cyl = (pos.z - boundsMin.z) / C; + + // Seam smoothing: cross-fade between left-side and right-side texture + // continuations at the atan2 wrap point. Each side samples the texture + // with a smoothly varying UV (no discontinuity), preserving full detail. + float seamBand = seamBandWidth * 0.1; + float seamDist = min(u_cyl, 1.0 - u_cyl); + float hSide; + if (seamBand > 0.001 && seamDist < seamBand) { + float d = u_cyl < 0.5 ? u_cyl : u_cyl - 1.0; + float t = smoothstep(0.0, 1.0, (d + seamBand) / (2.0 * seamBand)); + float hLeft = sampleMap(vec2(1.0 + d, v_cyl)); + float hRight = sampleMap(vec2(d, v_cyl)); + hSide = mix(hLeft, hRight, t); + } else { + hSide = sampleMap(vec2(u_cyl, v_cyl)); + } + if (mappingBlend < 0.001) return hSide; - float blendHalf = mappingBlend * 0.20; - float capW = smoothstep(0.7 - blendHalf, 0.7 + blendHalf, abs(blendN.z)); + float capThreshold = cos(radians(capAngle)); + float blendHalf = seamBandWidth * 0.5; + float capW = smoothstep(capThreshold - blendHalf, capThreshold + blendHalf, abs(blendN.z)); float hCap = sampleMap(vec2(rel.x / C + 0.5, rel.y / C + 0.5)); return mix(hSide, hCap, capW); } else if (mappingMode == 4) { float r = length(rel); float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0)); - float theta = atan(rel.y, rel.x); - return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI)); + float u_sph = atan(rel.y, rel.x) / TWO_PI + 0.5; + float v_sph = phi / PI; + + // Seam smoothing: cross-fade at the atan2 wrap + float seamBand = seamBandWidth * 0.1; + float seamDist = min(u_sph, 1.0 - u_sph); + if (seamBand > 0.001 && seamDist < seamBand) { + float d = u_sph < 0.5 ? u_sph : u_sph - 1.0; + float t = smoothstep(0.0, 1.0, (d + seamBand) / (2.0 * seamBand)); + float hLeft = sampleMap(vec2(1.0 + d, v_sph)); + float hRight = sampleMap(vec2(d, v_sph)); + return mix(hLeft, hRight, t); + } + return sampleMap(vec2(u_sph, v_sph)); } else if (mappingMode == 5) { vec3 blend = abs(projN); @@ -321,6 +352,7 @@ export function updateMaterial(material, displacementTexture, settings) { u.topAngleLimit.value = settings.topAngleLimit ?? 0.0; u.mappingBlend.value = settings.mappingBlend ?? 0.0; u.seamBandWidth.value = settings.seamBandWidth ?? 0.35; + u.capAngle.value = settings.capAngle ?? 20.0; u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0; u.useDisplacement.value = settings.useDisplacement ? 1 : 0; } @@ -347,6 +379,7 @@ function buildUniforms(tex, settings) { topAngleLimit: { value: settings.topAngleLimit ?? 0.0 }, mappingBlend: { value: settings.mappingBlend ?? 0.0 }, seamBandWidth: { value: settings.seamBandWidth ?? 0.35 }, + capAngle: { value: settings.capAngle ?? 20.0 }, symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 }, useDisplacement: { value: settings.useDisplacement ? 1 : 0 }, };