mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: add cap angle control for cylindrical mapping and update UV calculations
This commit is contained in:
@@ -147,6 +147,11 @@
|
|||||||
<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>
|
||||||
|
<div class="form-row slider-row" id="cap-angle-row" style="display:none">
|
||||||
|
<label for="cap-angle" title="Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.">Cap Angle ⓘ</label>
|
||||||
|
<input type="range" id="cap-angle" min="1" max="89" step="1" value="20" />
|
||||||
|
<input type="number" class="val" id="cap-angle-val" value="20" min="1" max="89" step="1" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Displacement -->
|
<!-- Displacement -->
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const settings = {
|
|||||||
topAngleLimit: 0,
|
topAngleLimit: 0,
|
||||||
mappingBlend: 1,
|
mappingBlend: 1,
|
||||||
seamBandWidth: 0.5,
|
seamBandWidth: 0.5,
|
||||||
|
capAngle: 20,
|
||||||
symmetricDisplacement: false,
|
symmetricDisplacement: false,
|
||||||
useDisplacement: false,
|
useDisplacement: false,
|
||||||
};
|
};
|
||||||
@@ -111,6 +112,9 @@ 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 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 symmetricDispToggle = document.getElementById('symmetric-displacement');
|
||||||
const dispPreviewToggle = document.getElementById('displacement-preview');
|
const dispPreviewToggle = document.getElementById('displacement-preview');
|
||||||
|
|
||||||
@@ -279,6 +283,7 @@ function wireEvents() {
|
|||||||
// ── Settings ──
|
// ── Settings ──
|
||||||
mappingSelect.addEventListener('change', () => {
|
mappingSelect.addEventListener('change', () => {
|
||||||
settings.mappingMode = parseInt(mappingSelect.value, 10);
|
settings.mappingMode = parseInt(mappingSelect.value, 10);
|
||||||
|
capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none';
|
||||||
updatePreview();
|
updatePreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,6 +337,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(capAngleSlider, capAngleVal, v => { settings.capAngle = v; return Math.round(v); });
|
||||||
symmetricDispToggle.addEventListener('change', () => {
|
symmetricDispToggle.addEventListener('change', () => {
|
||||||
settings.symmetricDisplacement = symmetricDispToggle.checked;
|
settings.symmetricDisplacement = symmetricDispToggle.checked;
|
||||||
updatePreview();
|
updatePreview();
|
||||||
@@ -1046,6 +1052,7 @@ async function handleSTL(file) {
|
|||||||
if (swatches.length > 0) swatches[0].classList.add('active');
|
if (swatches.length > 0) swatches[0].classList.add('active');
|
||||||
}
|
}
|
||||||
mappingSelect.value = String(settings.mappingMode);
|
mappingSelect.value = String(settings.mappingMode);
|
||||||
|
capAngleRow.style.display = settings.mappingMode === 3 ? '' : 'none';
|
||||||
|
|
||||||
// Show mesh with a default material until a map is selected
|
// Show mesh with a default material until a map is selected
|
||||||
loadGeometry(geometry);
|
loadGeometry(geometry);
|
||||||
|
|||||||
+67
-21
@@ -133,41 +133,66 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
|
|
||||||
case MODE_CYLINDRICAL: {
|
case MODE_CYLINDRICAL: {
|
||||||
// mappingBlend=0 → pure side projection for all faces (original behaviour, no cap seam).
|
// 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 r = Math.max(size.x, size.y) * 0.5;
|
||||||
const C = TWO_PI * Math.max(r, 1e-6);
|
const C = TWO_PI * Math.max(r, 1e-6);
|
||||||
const rx = pos.x - center.x;
|
const rx = pos.x - center.x;
|
||||||
const ry = pos.y - center.y;
|
const ry = pos.y - center.y;
|
||||||
const blend = settings.mappingBlend ?? 0.0;
|
const blend = settings.mappingBlend ?? 0.0;
|
||||||
const theta = Math.atan2(ry, rx);
|
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;
|
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) {
|
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 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) {
|
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 uCap = rx / C + 0.5;
|
||||||
const vCap = ry / C + 0.5;
|
const vCap = ry / C + 0.5;
|
||||||
|
const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
|
|
||||||
if (capW >= 1) {
|
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
|
// Combine seam-blended side samples with cap sample
|
||||||
// produces garbage values in the transition zone).
|
const samples = sideSamples.map(s => ({ u: s.u, v: s.v, w: s.w * (1 - capW) }));
|
||||||
const tSide = applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad);
|
samples.push({ u: tCap.u, v: tCap.v, w: capW });
|
||||||
const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad);
|
return { triplanar: true, samples };
|
||||||
return {
|
|
||||||
triplanar: true,
|
|
||||||
samples: [
|
|
||||||
{ u: tSide.u, v: tSide.v, w: 1 - capW },
|
|
||||||
{ u: tCap.u, v: tCap.v, w: capW },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case MODE_SPHERICAL: {
|
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 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 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]
|
const theta = Math.atan2(ry, rx); // [-PI, PI]
|
||||||
u = (theta / TWO_PI) + 0.5;
|
const uRaw = (theta / TWO_PI) + 0.5;
|
||||||
v = phi / Math.PI;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-6
@@ -35,6 +35,7 @@ const sharedGLSL = /* glsl */`
|
|||||||
uniform float topAngleLimit;
|
uniform float topAngleLimit;
|
||||||
uniform float mappingBlend;
|
uniform float mappingBlend;
|
||||||
uniform float seamBandWidth;
|
uniform float seamBandWidth;
|
||||||
|
uniform float capAngle;
|
||||||
uniform int symmetricDisplacement;
|
uniform int symmetricDisplacement;
|
||||||
uniform int useDisplacement;
|
uniform int useDisplacement;
|
||||||
|
|
||||||
@@ -110,19 +111,49 @@ const sharedGLSL = /* glsl */`
|
|||||||
} else if (mappingMode == 3) {
|
} else if (mappingMode == 3) {
|
||||||
float r = max(boundsSize.x, boundsSize.y) * 0.5;
|
float r = max(boundsSize.x, boundsSize.y) * 0.5;
|
||||||
float C = TWO_PI * max(r, 1e-4);
|
float C = TWO_PI * max(r, 1e-4);
|
||||||
float hSide = sampleMap(vec2(atan(rel.y, rel.x) / TWO_PI + 0.5,
|
float u_cyl = atan(rel.y, rel.x) / TWO_PI + 0.5;
|
||||||
(pos.z - boundsMin.z) / C));
|
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;
|
if (mappingBlend < 0.001) return hSide;
|
||||||
float blendHalf = mappingBlend * 0.20;
|
float capThreshold = cos(radians(capAngle));
|
||||||
float capW = smoothstep(0.7 - blendHalf, 0.7 + blendHalf, abs(blendN.z));
|
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));
|
float hCap = sampleMap(vec2(rel.x / C + 0.5, rel.y / C + 0.5));
|
||||||
return mix(hSide, hCap, capW);
|
return mix(hSide, hCap, capW);
|
||||||
|
|
||||||
} else if (mappingMode == 4) {
|
} else if (mappingMode == 4) {
|
||||||
float r = length(rel);
|
float r = length(rel);
|
||||||
float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0));
|
float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0));
|
||||||
float theta = atan(rel.y, rel.x);
|
float u_sph = atan(rel.y, rel.x) / TWO_PI + 0.5;
|
||||||
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
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) {
|
} else if (mappingMode == 5) {
|
||||||
vec3 blend = abs(projN);
|
vec3 blend = abs(projN);
|
||||||
@@ -321,6 +352,7 @@ export function updateMaterial(material, displacementTexture, settings) {
|
|||||||
u.topAngleLimit.value = settings.topAngleLimit ?? 0.0;
|
u.topAngleLimit.value = settings.topAngleLimit ?? 0.0;
|
||||||
u.mappingBlend.value = settings.mappingBlend ?? 0.0;
|
u.mappingBlend.value = settings.mappingBlend ?? 0.0;
|
||||||
u.seamBandWidth.value = settings.seamBandWidth ?? 0.35;
|
u.seamBandWidth.value = settings.seamBandWidth ?? 0.35;
|
||||||
|
u.capAngle.value = settings.capAngle ?? 20.0;
|
||||||
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
||||||
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
||||||
}
|
}
|
||||||
@@ -347,6 +379,7 @@ function buildUniforms(tex, settings) {
|
|||||||
topAngleLimit: { value: settings.topAngleLimit ?? 0.0 },
|
topAngleLimit: { value: settings.topAngleLimit ?? 0.0 },
|
||||||
mappingBlend: { value: settings.mappingBlend ?? 0.0 },
|
mappingBlend: { value: settings.mappingBlend ?? 0.0 },
|
||||||
seamBandWidth: { value: settings.seamBandWidth ?? 0.35 },
|
seamBandWidth: { value: settings.seamBandWidth ?? 0.35 },
|
||||||
|
capAngle: { value: settings.capAngle ?? 20.0 },
|
||||||
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
||||||
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user