diff --git a/js/displacement.js b/js/displacement.js index 4b01f6a..8eb46b0 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -36,6 +36,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const edge2 = new THREE.Vector3(); const faceNrm = new THREE.Vector3(); + // Texture aspect correction so non-square textures keep their proportions. + // The shorter axis gets aspect > 1 so it tiles faster, making each tile + // proportionally shorter in world-space to match the texture's content. + const tmax = Math.max(imgWidth, imgHeight, 1); + const aspectU = tmax / Math.max(imgWidth, 1); + const aspectV = tmax / Math.max(imgHeight, 1); + const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV }; + const QUANT = 1e4; const posKey = (x, y, z) => `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; @@ -203,15 +211,21 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const rotRad = (settings.rotation ?? 0) * Math.PI / 180; let grey = 0; if (za[0] > 0) { // X-dominant zone → YZ projection - const uv = _cubicUV((tmpPos.y-bounds.min.y)/md, (tmpPos.z-bounds.min.z)/md, settings, rotRad); + let rawU = (tmpPos.y-bounds.min.y)/md; + if (sn[0] < 0) rawU = -rawU; // flip U for -X faces + const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[0]/total); } if (za[1] > 0) { // Y-dominant zone → XZ projection - const uv = _cubicUV((tmpPos.x-bounds.min.x)/md, (tmpPos.z-bounds.min.z)/md, settings, rotRad); + let rawU = (tmpPos.x-bounds.min.x)/md; + if (sn[1] > 0) rawU = -rawU; // flip U for +Y faces + const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[1]/total); } if (za[2] > 0) { // Z-dominant zone → XY projection - const uv = _cubicUV((tmpPos.x-bounds.min.x)/md, (tmpPos.y-bounds.min.y)/md, settings, rotRad); + let rawU = (tmpPos.x-bounds.min.x)/md; + if (sn[2] < 0) rawU = -rawU; // flip U for -Z faces + const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[2]/total); } dispCache.set(k, grey); @@ -221,7 +235,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett tmpNrm.set(sn[0], sn[1], sn[2]); - const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); + const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds); let grey; if (uvResult.triplanar) { grey = 0; @@ -321,6 +335,9 @@ function sampleBilinear(data, w, h, u, v) { // Ensure [0,1) — guard against floating-point edge cases u = ((u % 1) + 1) % 1; v = ((v % 1) + 1) % 1; + // Flip V to match WebGL/Three.js texture convention (flipY=true means + // v=0 is the bottom of the image, but ImageData row 0 is the top). + v = 1 - v; const fx = u * (w - 1); const fy = v * (h - 1); @@ -345,9 +362,9 @@ function sampleBilinear(data, w, h, u, v) { /** Apply scale/offset/rotation to raw UV for cubic projection. * Mirrors the private applyTransform helper in mapping.js. */ -function _cubicUV(rawU, rawV, settings, rotRad) { - let u = rawU / settings.scaleU + settings.offsetU; - let v = rawV / settings.scaleV + settings.offsetV; +function _cubicUV(rawU, rawV, settings, rotRad, aspectU, aspectV) { + let u = (rawU * aspectU) / settings.scaleU + settings.offsetU; + let v = (rawV * aspectV) / settings.scaleV + settings.offsetV; if (rotRad !== 0) { const c = Math.cos(rotRad), s = Math.sin(rotRad); u -= 0.5; v -= 0.5; diff --git a/js/main.js b/js/main.js index acfb0a1..45c05e0 100644 --- a/js/main.js +++ b/js/main.js @@ -1423,7 +1423,18 @@ function getEffectiveMapEntry() { function updatePreview() { if (!currentGeometry || !currentBounds) return; - const fullSettings = { ...settings, bounds: currentBounds }; + // Texture aspect correction so non-square textures keep their proportions. + // A 512×279 texture needs aspectV = 512/279 ≈ 1.84 so V tiles faster (more + // repetitions), making each tile shorter in world-space to match the texture's + // wider-than-tall content. The wider axis gets aspect = 1 (unchanged). + const tw = activeMapEntry?.width ?? 1, th = activeMapEntry?.height ?? 1; + const tmax = Math.max(tw, th, 1); + const fullSettings = { + ...settings, + bounds: currentBounds, + textureAspectU: tmax / Math.max(tw, 1), + textureAspectV: tmax / Math.max(th, 1), + }; if (!activeMapEntry) { // No map yet — plain material diff --git a/js/mapping.js b/js/mapping.js index 3bc1ed7..4b56b72 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -104,7 +104,13 @@ export function getCubicBlendWeights(normal, blend, seamBandWidth = 0.35) { */ export function computeUV(pos, normal, mode, settings, bounds) { const { min, size, center } = bounds; - const { scaleU, scaleV, offsetU, offsetV } = settings; + // Compensate for non-square textures: divide scale by aspect correction + // so equal world-space distances produce equal physical texture distances. + const aU = settings.textureAspectU ?? 1; + const aV = settings.textureAspectV ?? 1; + const scaleU = (settings.scaleU) / aU; + const scaleV = (settings.scaleV) / aV; + const { offsetU, offsetV } = settings; const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const maxDim = Math.max(size.x, size.y, size.z); const md = Math.max(maxDim, 1e-6); @@ -230,9 +236,17 @@ export function computeUV(pos, normal, mode, settings, bounds) { case MODE_CUBIC: { 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 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); + // Flip U based on normal sign so opposite faces show correct (non-mirrored) text. + // Derived from camera right = view_dir × up_dir for each face orientation (Z-up). + let yzU = (pos.y - min.y) / md; + if (normal.x < 0) yzU = -yzU; + let xzU = (pos.x - min.x) / md; + if (normal.y > 0) xzU = -xzU; + let xyU = (pos.x - min.x) / md; + if (normal.z < 0) xyU = -xyU; + const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); + const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); + const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad); if (weights.x > 0.999) return tYZ; if (weights.y > 0.999) return tXZ; @@ -263,18 +277,25 @@ export function computeUV(pos, normal, mode, settings, bounds) { const wy = by / sum; const wz = bz / sum; + // Flip U based on normal sign so opposite faces show correct (non-mirrored) text. + let yzU = (pos.y - min.y) / md; + if (normal.x < 0) yzU = -yzU; + let xzU = (pos.x - min.x) / md; + if (normal.y > 0) xzU = -xzU; + let xyU = (pos.x - min.x) / md; + if (normal.z < 0) xyU = -xyU; const uvXY = { - u: (pos.x - min.x) / md, + u: xyU, v: (pos.y - min.y) / md, w: wz, }; const uvXZ = { - u: (pos.x - min.x) / md, + u: xzU, v: (pos.z - min.z) / md, w: wy, }; const uvYZ = { - u: (pos.y - min.y) / md, + u: yzU, v: (pos.z - min.z) / md, w: wx, }; diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 096860c..31de791 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -38,6 +38,7 @@ const sharedGLSL = /* glsl */` uniform float capAngle; uniform int symmetricDisplacement; uniform int useDisplacement; + uniform vec2 textureAspect; const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; @@ -81,9 +82,9 @@ const sharedGLSL = /* glsl */` return blendedWeights / (dot(blendedWeights, vec3(1.0)) + 1e-6); } - // Sample after applying scale + tiling + // Sample after applying scale + tiling (aspect-corrected) float sampleMap(vec2 rawUV) { - vec2 uv = rawUV / scaleUV + offsetUV; + vec2 uv = (rawUV * textureAspect) / scaleUV + offsetUV; float c = cos(rotation); float s = sin(rotation); uv -= 0.5; uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); @@ -159,15 +160,29 @@ const sharedGLSL = /* glsl */` vec3 blend = abs(projN); blend = pow(blend, vec3(4.0)); blend /= dot(blend, vec3(1.0)) + 1e-4; - float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); - float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); - float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); + // Flip U based on normal sign so opposite faces show correct (non-mirrored) text. + float yzU = (pos.y - boundsMin.y) / md; + if (projN.x < 0.0) yzU = -yzU; + float xzU = (pos.x - boundsMin.x) / md; + if (projN.y > 0.0) xzU = -xzU; + float xyU = (pos.x - boundsMin.x) / md; + if (projN.z < 0.0) xyU = -xyU; + float hXY = sampleMap(vec2(xyU, (pos.y - boundsMin.y) / md)); + float hXZ = sampleMap(vec2(xzU, (pos.z - boundsMin.z) / md)); + float hYZ = sampleMap(vec2(yzU, (pos.z - boundsMin.z) / md)); return hXY * blend.z + hXZ * blend.y + hYZ * blend.x; } else { - 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)); + // Flip U based on normal sign so opposite faces show correct (non-mirrored) text. + float yzU = (pos.y - boundsMin.y) / md; + if (projN.x < 0.0) yzU = -yzU; + float xzU = (pos.x - boundsMin.x) / md; + if (projN.y > 0.0) xzU = -xzU; + float xyU = (pos.x - boundsMin.x) / md; + if (projN.z < 0.0) xyU = -xyU; + float hYZ = sampleMap(vec2(yzU, (pos.z - boundsMin.z) / md)); + float hXZ = sampleMap(vec2(xzU, (pos.z - boundsMin.z) / md)); + float hXY = sampleMap(vec2(xyU, (pos.y - boundsMin.y) / md)); vec3 bN = blendN; vec3 absFaceN = abs(projN); float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z)); @@ -355,6 +370,7 @@ export function updateMaterial(material, displacementTexture, settings) { u.capAngle.value = settings.capAngle ?? 20.0; u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0; u.useDisplacement.value = settings.useDisplacement ? 1 : 0; + u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1); } // ── Internal ────────────────────────────────────────────────────────────────── @@ -382,6 +398,7 @@ function buildUniforms(tex, settings) { capAngle: { value: settings.capAngle ?? 20.0 }, symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 }, useDisplacement: { value: settings.useDisplacement ? 1 : 0 }, + textureAspect: { value: new THREE.Vector2(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1) }, }; }