mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: enhance cubic mapping with smooth normals and blending weights for improved texture transitions
This commit is contained in:
+8
-7
@@ -55,7 +55,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
||||
// underlying geometry is still faceted (the subdivision didn't change it),
|
||||
// so printed edges remain sharp.
|
||||
|
||||
// ── Pass 1: accumulate area-weighted face normals per unique position ─────
|
||||
// ── Pass 1: accumulate area-weighted smooth normals per unique position ───
|
||||
// Map: posKey → [nx, ny, nz] (unnormalised sum)
|
||||
const smoothNrmMap = new Map();
|
||||
// zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only)
|
||||
@@ -137,13 +137,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
||||
tmpPos.fromBufferAttribute(posAttr, t + v);
|
||||
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
|
||||
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
|
||||
const existing = smoothNrmMap.get(k);
|
||||
if (existing) {
|
||||
existing[0] += faceNrm.x;
|
||||
existing[1] += faceNrm.y;
|
||||
existing[2] += faceNrm.z;
|
||||
existing[0] += tmpNrm.x * faceArea;
|
||||
existing[1] += tmpNrm.y * faceArea;
|
||||
existing[2] += tmpNrm.z * faceArea;
|
||||
} else {
|
||||
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
|
||||
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
|
||||
}
|
||||
if (czX > 0 || czY > 0 || czZ > 0) {
|
||||
const za = zoneAreaMap.get(k);
|
||||
@@ -176,12 +177,12 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
||||
|
||||
const sn = smoothNrmMap.get(k);
|
||||
|
||||
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
|
||||
// Cubic at sharp seams: zone-area-weighted sampling with a stable per-face dominant axis.
|
||||
// Non-seam vertices use their single zone purely; seam-edge vertices that
|
||||
// adjoin two zones get a face-area-proportional blend. This guarantees all
|
||||
// three vertices of every triangle receive consistent displacement, making
|
||||
// the mesh watertight with no mixed-projection artefact rows at the seam.
|
||||
if (settings.mappingMode === 6 /* MODE_CUBIC */) {
|
||||
if (settings.mappingMode === 6 /* MODE_CUBIC */ && (settings.mappingBlend ?? 0) < 0.001) {
|
||||
const za = zoneAreaMap.get(k);
|
||||
const total = za ? za[0] + za[1] + za[2] : 0;
|
||||
if (total > 0) {
|
||||
|
||||
+82
-16
@@ -27,6 +27,71 @@ export function getDominantCubicAxis(normal) {
|
||||
return 'z';
|
||||
}
|
||||
|
||||
export function isAmbiguousCubicNormal(normal) {
|
||||
const ax = Math.abs(normal.x);
|
||||
const ay = Math.abs(normal.y);
|
||||
const az = Math.abs(normal.z);
|
||||
const axis = getDominantCubicAxis(normal);
|
||||
const primary = axis === 'x' ? ax : axis === 'y' ? ay : az;
|
||||
const secondary = axis === 'x' ? Math.max(ay, az) : axis === 'y' ? Math.max(ax, az) : Math.max(ax, ay);
|
||||
return primary - secondary <= CUBIC_AXIS_EPSILON;
|
||||
}
|
||||
|
||||
export function getCubicBlendWeights(normal, blend) {
|
||||
const axis = getDominantCubicAxis(normal);
|
||||
const ax = Math.abs(normal.x);
|
||||
const ay = Math.abs(normal.y);
|
||||
const az = Math.abs(normal.z);
|
||||
const primary = axis === 'x' ? ax : axis === 'y' ? ay : az;
|
||||
const secondary = axis === 'x' ? Math.max(ay, az) : axis === 'y' ? Math.max(ax, az) : Math.max(ax, ay);
|
||||
|
||||
if (blend <= 0.001 || isAmbiguousCubicNormal(normal)) {
|
||||
return {
|
||||
x: axis === 'x' ? 1 : 0,
|
||||
y: axis === 'y' ? 1 : 0,
|
||||
z: axis === 'z' ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const oneHot = {
|
||||
x: axis === 'x' ? 1 : 0,
|
||||
y: axis === 'y' ? 1 : 0,
|
||||
z: axis === 'z' ? 1 : 0,
|
||||
};
|
||||
|
||||
// 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.
|
||||
const seamWidth = Math.max(blend * 0.35, CUBIC_AXIS_EPSILON * 2);
|
||||
const seamMixRaw = 1 - Math.min(1, Math.max(0, (primary - secondary) / seamWidth));
|
||||
const seamMix = blend * seamMixRaw * seamMixRaw * (3 - 2 * seamMixRaw);
|
||||
if (seamMix <= 0.001) return oneHot;
|
||||
|
||||
// blend=1 should produce a genuinely soft triplanar-style transition.
|
||||
// Lower blend values progressively sharpen the weights back toward a single
|
||||
// dominant axis without snapping until the slider reaches zero.
|
||||
const power = 1 + (1 - seamMix) * 11;
|
||||
const sx = Math.pow(ax, power);
|
||||
const sy = Math.pow(ay, power);
|
||||
const sz = Math.pow(az, power);
|
||||
const smoothSum = sx + sy + sz + 1e-6;
|
||||
const smooth = {
|
||||
x: sx / smoothSum,
|
||||
y: sy / smoothSum,
|
||||
z: sz / smoothSum,
|
||||
};
|
||||
|
||||
const mx = oneHot.x * (1 - seamMix) + smooth.x * seamMix;
|
||||
const my = oneHot.y * (1 - seamMix) + smooth.y * seamMix;
|
||||
const mz = oneHot.z * (1 - seamMix) + smooth.z * seamMix;
|
||||
const sum = mx + my + mz;
|
||||
|
||||
return {
|
||||
x: mx / sum,
|
||||
y: my / sum,
|
||||
z: mz / sum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute normalised UV coordinates [0, 1) (tiling) for a vertex.
|
||||
*
|
||||
@@ -118,22 +183,23 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
||||
}
|
||||
|
||||
case MODE_CUBIC: {
|
||||
let uRaw, vRaw;
|
||||
switch (getDominantCubicAxis(normal)) {
|
||||
case 'x':
|
||||
uRaw = (pos.y - min.y) / md;
|
||||
vRaw = (pos.z - min.z) / md;
|
||||
break;
|
||||
case 'y':
|
||||
uRaw = (pos.x - min.x) / md;
|
||||
vRaw = (pos.z - min.z) / md;
|
||||
break;
|
||||
default:
|
||||
uRaw = (pos.x - min.x) / md;
|
||||
vRaw = (pos.y - min.y) / md;
|
||||
break;
|
||||
}
|
||||
return applyTransform(uRaw, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||
const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0);
|
||||
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 tXY = applyTransform((pos.x - min.x) / md, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
|
||||
|
||||
if (weights.x > 0.999) return tYZ;
|
||||
if (weights.y > 0.999) return tXZ;
|
||||
if (weights.z > 0.999) return tXY;
|
||||
|
||||
return {
|
||||
triplanar: true,
|
||||
samples: [
|
||||
{ u: tXY.u, v: tXY.v, w: weights.z },
|
||||
{ u: tXZ.u, v: tXZ.v, w: weights.y },
|
||||
{ u: tYZ.u, v: tYZ.v, w: weights.x },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case MODE_TRIPLANAR:
|
||||
|
||||
+42
-6
@@ -54,7 +54,7 @@ const fragmentShader = /* glsl */`
|
||||
uniform vec3 boundsCenter;
|
||||
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
|
||||
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
|
||||
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended (cylindrical)
|
||||
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended
|
||||
|
||||
varying vec3 vModelPos;
|
||||
varying vec3 vModelNormal;
|
||||
@@ -72,6 +72,37 @@ const fragmentShader = /* glsl */`
|
||||
return 2;
|
||||
}
|
||||
|
||||
vec3 cubicBlendWeights(vec3 n) {
|
||||
vec3 absN = abs(n);
|
||||
int axis = dominantCubicAxis(n);
|
||||
float primary = axis == 0 ? absN.x : axis == 1 ? absN.y : absN.z;
|
||||
float secondary = axis == 0 ? max(absN.y, absN.z)
|
||||
: axis == 1 ? max(absN.x, absN.z)
|
||||
: max(absN.x, absN.y);
|
||||
|
||||
if (mappingBlend < 0.001 || primary - secondary <= CUBIC_AXIS_EPSILON) {
|
||||
if (axis == 0) return vec3(1.0, 0.0, 0.0);
|
||||
if (axis == 1) return vec3(0.0, 1.0, 0.0);
|
||||
return vec3(0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 oneHot = axis == 0 ? vec3(1.0, 0.0, 0.0)
|
||||
: axis == 1 ? vec3(0.0, 1.0, 0.0)
|
||||
: vec3(0.0, 0.0, 1.0);
|
||||
|
||||
float seamWidth = max(mappingBlend * 0.35, CUBIC_AXIS_EPSILON * 2.0);
|
||||
float seamMixRaw = 1.0 - clamp((primary - secondary) / seamWidth, 0.0, 1.0);
|
||||
float seamMix = mappingBlend * seamMixRaw * seamMixRaw * (3.0 - 2.0 * seamMixRaw);
|
||||
if (seamMix <= 0.001) return oneHot;
|
||||
|
||||
float power = 1.0 + (1.0 - seamMix) * 11.0;
|
||||
vec3 softWeights = pow(absN, vec3(power));
|
||||
softWeights /= dot(softWeights, vec3(1.0)) + 1e-6;
|
||||
|
||||
vec3 blendedWeights = mix(oneHot, softWeights, seamMix);
|
||||
return blendedWeights / (dot(blendedWeights, vec3(1.0)) + 1e-6);
|
||||
}
|
||||
|
||||
// Sample after applying scale + tiling
|
||||
float sampleMap(vec2 rawUV) {
|
||||
vec2 uv = rawUV / scaleUV + offsetUV;
|
||||
@@ -142,14 +173,19 @@ const fragmentShader = /* glsl */`
|
||||
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
||||
|
||||
} else {
|
||||
// Cubic (box) – always pick exactly one projection per triangle.
|
||||
// Cubic (box) – use smooth normals for blend weights so high blend values
|
||||
// can hide seams, but fall back to the face-stable triangle normal when
|
||||
// the triangle sits on an ambiguous near-45° tie.
|
||||
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
|
||||
float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md));
|
||||
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
||||
int axis = dominantCubicAxis(PN);
|
||||
if (axis == 0) return hYZ;
|
||||
if (axis == 1) return hXZ;
|
||||
return hXY;
|
||||
vec3 blendN = vModelNormal;
|
||||
vec3 absFaceN = abs(PN);
|
||||
float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z));
|
||||
float faceSecondary = absFaceN.x + absFaceN.y + absFaceN.z - facePrimary - min(absFaceN.x, min(absFaceN.y, absFaceN.z));
|
||||
if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) blendN = PN;
|
||||
vec3 wts = cubicBlendWeights(blendN);
|
||||
return hYZ * wts.x + hXZ * wts.y + hXY * wts.z;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -262,6 +262,7 @@ function toIndexed(geometry, nonIndexedWeights = null) {
|
||||
|
||||
const positions = [];
|
||||
const normals = [];
|
||||
const normalSums = [];
|
||||
const weights = nonIndexedWeights ? [] : null;
|
||||
const indices = [];
|
||||
const vertMap = new Map();
|
||||
@@ -281,15 +282,31 @@ function toIndexed(geometry, nonIndexedWeights = null) {
|
||||
idx = positions.length / 3;
|
||||
positions.push(px, py, pz);
|
||||
normals.push(nx_, ny_, nz_);
|
||||
normalSums.push(nx_, ny_, nz_);
|
||||
if (weights) weights.push(nonIndexedWeights[i]);
|
||||
vertMap.set(key, idx);
|
||||
} else if (weights && nonIndexedWeights[i] > weights[idx]) {
|
||||
// MAX: if any incident original face was excluded, the shared vertex is excluded
|
||||
weights[idx] = nonIndexedWeights[i];
|
||||
} else {
|
||||
normalSums[idx * 3] += nx_;
|
||||
normalSums[idx * 3 + 1] += ny_;
|
||||
normalSums[idx * 3 + 2] += nz_;
|
||||
if (weights && nonIndexedWeights[i] > weights[idx]) {
|
||||
// MAX: if any incident original face was excluded, the shared vertex is excluded
|
||||
weights[idx] = nonIndexedWeights[i];
|
||||
}
|
||||
}
|
||||
indices.push(idx);
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.length / 3; i++) {
|
||||
const nx = normalSums[i * 3];
|
||||
const ny = normalSums[i * 3 + 1];
|
||||
const nz = normalSums[i * 3 + 2];
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
normals[i * 3] = nx / len;
|
||||
normals[i * 3 + 1] = ny / len;
|
||||
normals[i * 3 + 2] = nz / len;
|
||||
}
|
||||
|
||||
return { positions, normals, weights, indices };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user