mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: add seam band width control and integrate with displacement logic for improved blending
This commit is contained in:
+6
-1
@@ -130,11 +130,16 @@
|
|||||||
<option value="2" data-i18n-opt="projection.planarYZ">Planar YZ</option>
|
<option value="2" data-i18n-opt="projection.planarYZ">Planar YZ</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row" style="display:none">
|
||||||
<label for="seam-blend" data-i18n="labels.seamBlend" data-i18n-title="tooltips.seamBlend" title="Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.">Seam Blend ⓘ</label>
|
<label for="seam-blend" data-i18n="labels.seamBlend" data-i18n-title="tooltips.seamBlend" title="Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.">Seam Blend ⓘ</label>
|
||||||
<input type="range" id="seam-blend" min="0" max="1" step="0.01" value="1" />
|
<input type="range" id="seam-blend" min="0" max="1" step="0.01" value="1" />
|
||||||
<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">
|
||||||
|
<label for="seam-band-width" 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>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Displacement -->
|
<!-- Displacement -->
|
||||||
|
|||||||
+2
-1
@@ -124,8 +124,9 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
let czX = 0, czY = 0, czZ = 0;
|
let czX = 0, czY = 0, czZ = 0;
|
||||||
if (settings.mappingMode === 6 && faceArea > 1e-12) {
|
if (settings.mappingMode === 6 && faceArea > 1e-12) {
|
||||||
const cubicBlend = settings.mappingBlend ?? 0;
|
const cubicBlend = settings.mappingBlend ?? 0;
|
||||||
|
const cubicBandWidth = settings.seamBandWidth ?? 0.35;
|
||||||
const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea };
|
const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea };
|
||||||
const w = getCubicBlendWeights(unitFaceNrm, cubicBlend);
|
const w = getCubicBlendWeights(unitFaceNrm, cubicBlend, cubicBandWidth);
|
||||||
czX = w.x * faceArea;
|
czX = w.x * faceArea;
|
||||||
czY = w.y * faceArea;
|
czY = w.y * faceArea;
|
||||||
czZ = w.z * faceArea;
|
czZ = w.z * faceArea;
|
||||||
|
|||||||
+61
@@ -51,6 +51,7 @@ const settings = {
|
|||||||
bottomAngleLimit: 5,
|
bottomAngleLimit: 5,
|
||||||
topAngleLimit: 0,
|
topAngleLimit: 0,
|
||||||
mappingBlend: 1,
|
mappingBlend: 1,
|
||||||
|
seamBandWidth: 0.5,
|
||||||
symmetricDisplacement: false,
|
symmetricDisplacement: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,6 +101,8 @@ const bottomAngleLimitVal = document.getElementById('bottom-angle-limit-val')
|
|||||||
const topAngleLimitVal = document.getElementById('top-angle-limit-val');
|
const topAngleLimitVal = document.getElementById('top-angle-limit-val');
|
||||||
const seamBlendSlider = document.getElementById('seam-blend');
|
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 seamBandWidthVal = document.getElementById('seam-band-width-val');
|
||||||
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
||||||
|
|
||||||
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
|
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
|
||||||
@@ -166,6 +169,7 @@ scaleVVal.value = posToScale(parseFloat(scaleVSlider.value));
|
|||||||
loadPresets().then(presets => {
|
loadPresets().then(presets => {
|
||||||
PRESETS = presets;
|
PRESETS = presets;
|
||||||
buildPresetGrid();
|
buildPresetGrid();
|
||||||
|
loadDefaultCube();
|
||||||
// Select Crystal as the default preset
|
// Select Crystal as the default preset
|
||||||
const noiseIdx = PRESETS.findIndex(p => p.name === 'Crystal');
|
const noiseIdx = PRESETS.findIndex(p => p.name === 'Crystal');
|
||||||
const defaultIdx = noiseIdx !== -1 ? noiseIdx : 0;
|
const defaultIdx = noiseIdx !== -1 ? noiseIdx : 0;
|
||||||
@@ -312,6 +316,7 @@ function wireEvents() {
|
|||||||
linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; });
|
linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; });
|
||||||
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); });
|
||||||
symmetricDispToggle.addEventListener('change', () => {
|
symmetricDispToggle.addEventListener('change', () => {
|
||||||
settings.symmetricDisplacement = symmetricDispToggle.checked;
|
settings.symmetricDisplacement = symmetricDispToggle.checked;
|
||||||
updatePreview();
|
updatePreview();
|
||||||
@@ -675,6 +680,62 @@ function formatM(n) {
|
|||||||
|
|
||||||
// ── STL loading ───────────────────────────────────────────────────────────────
|
// ── STL loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadDefaultCube() {
|
||||||
|
// Create a 50×50×50 mm box; convert to non-indexed so it behaves like a
|
||||||
|
// real STL (buildAdjacency and displacement expect non-indexed geometry).
|
||||||
|
const geo = new THREE.BoxGeometry(50, 50, 50).toNonIndexed();
|
||||||
|
geo.computeBoundingBox();
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
|
||||||
|
currentGeometry = geo;
|
||||||
|
currentBounds = computeBounds(geo);
|
||||||
|
currentStlName = 'cube_50x50x50';
|
||||||
|
checkAmplitudeWarning();
|
||||||
|
|
||||||
|
loadGeometry(geo);
|
||||||
|
dropHint.classList.add('hidden');
|
||||||
|
|
||||||
|
// Reset exclusion state
|
||||||
|
excludedFaces = new Set();
|
||||||
|
exclusionTool = null;
|
||||||
|
eraseMode = false;
|
||||||
|
isPainting = false;
|
||||||
|
exclBrushBtn.classList.remove('active');
|
||||||
|
exclBucketBtn.classList.remove('active');
|
||||||
|
exclEraseToggle.classList.remove('active');
|
||||||
|
exclBrushTypeRow.classList.add('hidden');
|
||||||
|
exclRadiusRow.classList.add('hidden');
|
||||||
|
exclThresholdRow.classList.add('hidden');
|
||||||
|
canvas.style.cursor = '';
|
||||||
|
setExclusionOverlay(null);
|
||||||
|
setHoverPreview(null);
|
||||||
|
_lastHoverTriIdx = -1;
|
||||||
|
exclCount.textContent = t('excl.initExcluded');
|
||||||
|
|
||||||
|
const adjData = buildAdjacency(geo);
|
||||||
|
triangleAdjacency = adjData.adjacency;
|
||||||
|
triangleCentroids = adjData.centroids;
|
||||||
|
|
||||||
|
settings.scaleU = 0.5; scaleUSlider.value = scaleToPos(0.5); scaleUVal.value = 0.5;
|
||||||
|
settings.scaleV = 0.5; scaleVSlider.value = scaleToPos(0.5); scaleVVal.value = 0.5;
|
||||||
|
settings.offsetU = 0; offsetUSlider.value = 0; offsetUVal.value = 0;
|
||||||
|
settings.offsetV = 0; offsetVSlider.value = 0; offsetVVal.value = 0;
|
||||||
|
triLimitWarning.classList.add('hidden');
|
||||||
|
|
||||||
|
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
|
||||||
|
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
||||||
|
settings.refineLength = defaultEdge;
|
||||||
|
refineLenSlider.value = defaultEdge;
|
||||||
|
refineLenVal.value = defaultEdge;
|
||||||
|
|
||||||
|
const triCount = getTriangleCount(geo);
|
||||||
|
const mb = ((geo.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
||||||
|
meshInfo.textContent = t('ui.meshInfo', { n: triCount.toLocaleString(), mb });
|
||||||
|
|
||||||
|
exportBtn.disabled = (activeMapEntry === null);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSTL(file) {
|
async function handleSTL(file) {
|
||||||
try {
|
try {
|
||||||
const { geometry, bounds } = await loadSTLFile(file);
|
const { geometry, bounds } = await loadSTLFile(file);
|
||||||
|
|||||||
+3
-3
@@ -37,7 +37,7 @@ export function isAmbiguousCubicNormal(normal) {
|
|||||||
return primary - secondary <= CUBIC_AXIS_EPSILON;
|
return primary - secondary <= CUBIC_AXIS_EPSILON;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCubicBlendWeights(normal, blend) {
|
export function getCubicBlendWeights(normal, blend, seamBandWidth = 0.35) {
|
||||||
const axis = getDominantCubicAxis(normal);
|
const axis = getDominantCubicAxis(normal);
|
||||||
const ax = Math.abs(normal.x);
|
const ax = Math.abs(normal.x);
|
||||||
const ay = Math.abs(normal.y);
|
const ay = Math.abs(normal.y);
|
||||||
@@ -61,7 +61,7 @@ export function getCubicBlendWeights(normal, blend) {
|
|||||||
|
|
||||||
// Only blend inside a seam band around the cube-face boundary. This keeps
|
// Only blend inside a seam band around the cube-face boundary. This keeps
|
||||||
// strongly dominant faces fully textured even when the slider is barely on.
|
// strongly dominant faces fully textured even when the slider is barely on.
|
||||||
const seamWidth = Math.max(blend * 0.35, CUBIC_AXIS_EPSILON * 2);
|
const seamWidth = Math.max(seamBandWidth, CUBIC_AXIS_EPSILON * 2);
|
||||||
const seamMixRaw = 1 - Math.min(1, Math.max(0, (primary - secondary) / seamWidth));
|
const seamMixRaw = 1 - Math.min(1, Math.max(0, (primary - secondary) / seamWidth));
|
||||||
const seamMix = blend * seamMixRaw * seamMixRaw * (3 - 2 * seamMixRaw);
|
const seamMix = blend * seamMixRaw * seamMixRaw * (3 - 2 * seamMixRaw);
|
||||||
if (seamMix <= 0.001) return oneHot;
|
if (seamMix <= 0.001) return oneHot;
|
||||||
@@ -183,7 +183,7 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case MODE_CUBIC: {
|
case MODE_CUBIC: {
|
||||||
const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0);
|
const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0, settings.seamBandWidth ?? 0.35);
|
||||||
const tYZ = applyTransform((pos.y - min.y) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
const tYZ = applyTransform((pos.y - min.y) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
const tXZ = applyTransform((pos.x - min.x) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
const tXZ = applyTransform((pos.x - min.x) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
const tXY = applyTransform((pos.x - min.x) / md, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
const tXY = applyTransform((pos.x - min.x) / md, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const fragmentShader = /* glsl */`
|
|||||||
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
|
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
|
||||||
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
|
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
|
||||||
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended
|
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended
|
||||||
|
uniform float seamBandWidth; // width of the blend zone near cube-face seams
|
||||||
uniform int symmetricDisplacement; // 1 = remap [0,1]→[-1,1] so 50% grey = no disp
|
uniform int symmetricDisplacement; // 1 = remap [0,1]→[-1,1] so 50% grey = no disp
|
||||||
|
|
||||||
varying vec3 vModelPos;
|
varying vec3 vModelPos;
|
||||||
@@ -91,7 +92,7 @@ const fragmentShader = /* glsl */`
|
|||||||
: axis == 1 ? vec3(0.0, 1.0, 0.0)
|
: axis == 1 ? vec3(0.0, 1.0, 0.0)
|
||||||
: vec3(0.0, 0.0, 1.0);
|
: vec3(0.0, 0.0, 1.0);
|
||||||
|
|
||||||
float seamWidth = max(mappingBlend * 0.35, CUBIC_AXIS_EPSILON * 2.0);
|
float seamWidth = max(seamBandWidth, CUBIC_AXIS_EPSILON * 2.0);
|
||||||
float seamMixRaw = 1.0 - clamp((primary - secondary) / seamWidth, 0.0, 1.0);
|
float seamMixRaw = 1.0 - clamp((primary - secondary) / seamWidth, 0.0, 1.0);
|
||||||
float seamMix = mappingBlend * seamMixRaw * seamMixRaw * (3.0 - 2.0 * seamMixRaw);
|
float seamMix = mappingBlend * seamMixRaw * seamMixRaw * (3.0 - 2.0 * seamMixRaw);
|
||||||
if (seamMix <= 0.001) return oneHot;
|
if (seamMix <= 0.001) return oneHot;
|
||||||
@@ -290,6 +291,7 @@ export function updateMaterial(material, displacementTexture, settings) {
|
|||||||
u.bottomAngleLimit.value = settings.bottomAngleLimit ?? 5.0;
|
u.bottomAngleLimit.value = settings.bottomAngleLimit ?? 5.0;
|
||||||
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.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +316,7 @@ function buildUniforms(tex, settings) {
|
|||||||
bottomAngleLimit: { value: settings.bottomAngleLimit ?? 5.0 },
|
bottomAngleLimit: { value: settings.bottomAngleLimit ?? 5.0 },
|
||||||
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 },
|
||||||
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -341,6 +341,7 @@ export function setSceneBackground(hexColor) {
|
|||||||
export function setViewerTheme(isLight) {
|
export function setViewerTheme(isLight) {
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
scene.background = new THREE.Color(isLight ? 0xf0f0f5 : 0x111114);
|
scene.background = new THREE.Color(isLight ? 0xf0f0f5 : 0x111114);
|
||||||
|
const savedZ = grid ? grid.position.z : 0;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
scene.remove(grid);
|
scene.remove(grid);
|
||||||
grid.geometry.dispose();
|
grid.geometry.dispose();
|
||||||
@@ -352,7 +353,7 @@ export function setViewerTheme(isLight) {
|
|||||||
isLight ? 0xd0d0e0 : 0x1e1e24
|
isLight ? 0xd0d0e0 : 0x1e1e24
|
||||||
);
|
);
|
||||||
grid.rotation.x = Math.PI / 2;
|
grid.rotation.x = Math.PI / 2;
|
||||||
grid.position.z = 0;
|
grid.position.z = savedZ;
|
||||||
scene.add(grid);
|
scene.add(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user