From 32cc538bfb5288ca84e3090ce0bd36529548e321 Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Thu, 19 Mar 2026 13:36:21 +0100 Subject: [PATCH] feat: enhance cubic mapping with smooth normals and blending weights for improved texture transitions --- js/displacement.js | 15 +++---- js/mapping.js | 98 ++++++++++++++++++++++++++++++++++++------- js/previewMaterial.js | 48 ++++++++++++++++++--- js/subdivision.js | 23 ++++++++-- 4 files changed, 152 insertions(+), 32 deletions(-) diff --git a/js/displacement.js b/js/displacement.js index 3325bef..f3c1a99 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -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) { diff --git a/js/mapping.js b/js/mapping.js index 220dbbc..92c31a1 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -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: diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 1752234..933fac6 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -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; } } diff --git a/js/subdivision.js b/js/subdivision.js index 095a9ee..3e41dd1 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -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 }; }