From 32eddcad3765caec9582af18d5612b97d269d7f6 Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Thu, 19 Mar 2026 12:08:14 +0100 Subject: [PATCH] feat: add seam blend feature and amplitude overlap warning with UI updates --- index.html | 16 ++++-- js/displacement.js | 76 +++++++++++++++++++++++++++- js/i18n.js | 14 ++++++ js/main.js | 28 ++++++++--- js/mapping.js | 86 +++++++++++++++++++++----------- js/previewMaterial.js | 112 +++++++++++++++++++++++------------------- style.css | 23 +++++++++ 7 files changed, 264 insertions(+), 91 deletions(-) diff --git a/index.html b/index.html index 7adb34f..867b865 100644 --- a/index.html +++ b/index.html @@ -130,6 +130,11 @@ +
+ + + +
@@ -140,6 +145,9 @@ + @@ -148,8 +156,8 @@
- - + +
@@ -164,8 +172,8 @@
- - + +
diff --git a/js/displacement.js b/js/displacement.js index 58b1ef1..3325bef 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { computeUV } from './mapping.js'; +import { computeUV, getDominantCubicAxis } from './mapping.js'; /** * Apply displacement to every vertex of a non-indexed BufferGeometry. @@ -58,6 +58,11 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett // ── Pass 1: accumulate area-weighted face normals per unique position ───── // Map: posKey → [nx, ny, nz] (unnormalised sum) const smoothNrmMap = new Map(); + // zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only) + // Tracks the total adjacent face area in each cubic projection zone (X/Y/Z dominant). + // Seam-edge vertices that border two zones get a blend proportional to face area, + // eliminating the mixed-projection artefact on seam-crossing triangles. + const zoneAreaMap = new Map(); // maskedFracMap: posKey → [maskedArea, totalArea] // Tracks the fraction of surrounding face area that is masked so boundary // vertices get a smooth displacement blend instead of a hard on/off cutoff. @@ -108,6 +113,26 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const faceMasked = angleMasked; if (userExcluded && userExcludedFaces) userExcludedFaces[t / 3] = 1; + // For cubic mapping: assign this face's area to its single dominant zone (argmax). + // Seam-edge vertices that border two zones still accumulate proportional blending + // because those two different adjacent faces each contribute to their own zone. + // Using argmax (instead of all-three-components) ensures that a face at exactly 45° + // picks one projection consistently, eliminating the double-texture artefact. + let czX = 0, czY = 0, czZ = 0; + if (settings.mappingMode === 6 && faceArea > 1e-12) { + switch (getDominantCubicAxis(faceNrm)) { + case 'x': + czX = faceArea; + break; + case 'y': + czY = faceArea; + break; + default: + czZ = faceArea; + break; + } + } + for (let v = 0; v < 3; v++) { tmpPos.fromBufferAttribute(posAttr, t + v); const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); @@ -120,6 +145,11 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett } else { smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]); } + if (czX > 0 || czY > 0 || czZ > 0) { + const za = zoneAreaMap.get(k); + if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; } + else { zoneAreaMap.set(k, [czX, czY, czZ]); } + } const mf = maskedFracMap.get(k); if (mf) { if (faceMasked) mf[0] += faceArea; @@ -145,6 +175,36 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett if (dispCache.has(k)) continue; const sn = smoothNrmMap.get(k); + + // Cubic: 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 */) { + const za = zoneAreaMap.get(k); + const total = za ? za[0] + za[1] + za[2] : 0; + if (total > 0) { + const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6); + 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); + 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); + 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); + grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[2]/total); + } + dispCache.set(k, grey); + continue; + } + } + tmpNrm.set(sn[0], sn[1], sn[2]); const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); @@ -267,3 +327,17 @@ function sampleBilinear(data, w, h, u, v) { + v01 * (1-tx) * ty + v11 * tx * ty; } + +/** 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; + if (rotRad !== 0) { + const c = Math.cos(rotRad), s = Math.sin(rotRad); + u -= 0.5; v -= 0.5; + const ru = c*u - s*v, rv = s*u + c*v; + u = ru + 0.5; v = rv + 0.5; + } + return { u: u - Math.floor(u), v: v - Math.floor(v) }; +} diff --git a/js/i18n.js b/js/i18n.js index 194767e..8a9f5f3 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -50,6 +50,10 @@ export const TRANSLATIONS = { 'sections.displacement': 'Texture Depth', 'labels.amplitude': 'Amplitude', + // Seam blend + 'labels.seamBlend': 'Seam Blend \u24d8', + 'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.', + // Surface mask section 'sections.surfaceMask': 'Surface Mask \u24d8', 'tooltips.surfaceMask': '0° = no masking. Surfaces within this angle of horizontal will not be textured.', @@ -88,6 +92,9 @@ export const TRANSLATIONS = { 'excl.hintExclude': 'Excluded surfaces appear orange and will not receive displacement during export.', 'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.', + // Amplitude overlap warning + 'warnings.amplitudeOverlap': '\u26a0 Amplitude exceeds 10% of the smallest model dimension \u2014 geometry overlaps may occur in the exported STL.', + // Export section 'sections.export': 'Export \u24d8', 'tooltips.export': 'Smaller edge length = finer displacement detail. Output is then decimated to the triangle limit.', @@ -172,6 +179,10 @@ export const TRANSLATIONS = { 'sections.displacement': 'Texturtiefe', 'labels.amplitude': 'Amplitude', + // Seam blend + 'labels.seamBlend': 'Nahtglättung \u24d8', + 'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.', + // Surface mask section 'sections.surfaceMask': 'Fl\u00e4chenmaskierung nach Winkel\u24d8', 'tooltips.surfaceMask': '0° = keine Maskierung. Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen werden nicht texturiert.', @@ -210,6 +221,9 @@ export const TRANSLATIONS = { 'excl.hintExclude': 'Ausgeschlossene Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.', 'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.', + // Amplitude overlap warning + 'warnings.amplitudeOverlap': '\u26a0 Amplitude überschreitet 10% der kleinsten Modellabmessung \u2014 beim Export k\u00f6nnen Geometrie\u00fcberschneidungen auftreten.', + // Export section 'sections.export': 'Export \u24d8', 'tooltips.export': 'Kleinere Kantenl\u00e4nge = mehr Texturdetails. Die Ausgabe wird dann auf das Dreieckslimit vereinfacht.', diff --git a/js/main.js b/js/main.js index a228956..4193e74 100644 --- a/js/main.js +++ b/js/main.js @@ -39,8 +39,8 @@ const _raycaster = new THREE.Raycaster(); const settings = { mappingMode: 5, // Triplanar default - scaleU: 1.0, - scaleV: 1.0, + scaleU: 0.5, + scaleV: 0.5, amplitude: 0.5, offsetU: 0.0, offsetV: 0.0, @@ -50,6 +50,7 @@ const settings = { lockScale: true, bottomAngleLimit: 5, topAngleLimit: 0, + mappingBlend: 0.2, }; // ── DOM refs ────────────────────────────────────────────────────────────────── @@ -87,7 +88,8 @@ const offsetUVal = document.getElementById('offset-u-val'); const offsetVVal = document.getElementById('offset-v-val'); const rotationSlider = document.getElementById('rotation'); const rotationVal = document.getElementById('rotation-val'); -const amplitudeVal = document.getElementById('amplitude-val'); +const amplitudeVal = document.getElementById('amplitude-val'); +const amplitudeWarning = document.getElementById('amplitude-warning'); const refineLenVal = document.getElementById('refine-length-val'); const maxTriVal = document.getElementById('max-triangles-val'); @@ -95,6 +97,8 @@ const bottomAngleLimitSlider = document.getElementById('bottom-angle-limit'); const topAngleLimitSlider = document.getElementById('top-angle-limit'); const bottomAngleLimitVal = document.getElementById('bottom-angle-limit-val'); const topAngleLimitVal = document.getElementById('top-angle-limit-val'); +const seamBlendSlider = document.getElementById('seam-blend'); +const seamBlendVal = document.getElementById('seam-blend-val'); // ── Exclusion panel DOM refs ────────────────────────────────────────────────── const exclBrushBtn = document.getElementById('excl-brush-btn'); @@ -160,8 +164,8 @@ scaleVVal.value = posToScale(parseFloat(scaleVSlider.value)); loadPresets().then(presets => { PRESETS = presets; buildPresetGrid(); - // Select Noise as the default preset - const noiseIdx = PRESETS.findIndex(p => p.name === 'Noise'); + // Select Crystal as the default preset + const noiseIdx = PRESETS.findIndex(p => p.name === 'Crystal'); const defaultIdx = noiseIdx !== -1 ? noiseIdx : 0; const swatches = presetGrid.querySelectorAll('.preset-swatch'); if (swatches[defaultIdx]) selectPreset(defaultIdx, swatches[defaultIdx]); @@ -299,11 +303,13 @@ function wireEvents() { linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); }); linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); }); linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); }); - linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); }); + linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); }); + amplitudeVal.addEventListener('change', checkAmplitudeWarning); linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(2); }, false); linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false); linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; }); linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; }); + linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); }); // ── Export ── exportBtn.addEventListener('click', () => { @@ -669,6 +675,7 @@ async function handleSTL(file) { currentGeometry = geometry; currentBounds = bounds; currentStlName = file.name.replace(/\.stl$/i, ''); + checkAmplitudeWarning(); // Dispose old preview material and reset state for the new mesh if (previewMaterial) { @@ -743,6 +750,15 @@ async function handleSTL(file) { // ── Live preview ────────────────────────────────────────────────────────────── +function checkAmplitudeWarning() { + if (!currentBounds) return; + const minDim = Math.min(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z); + const danger = Math.abs(settings.amplitude) > minDim * 0.1; + amplitudeWarning.classList.toggle('hidden', !danger); + amplitudeSlider.classList.toggle('amp-danger', danger); + amplitudeVal.classList.toggle('amp-danger', danger); +} + function updatePreview() { if (!currentGeometry || !currentBounds) return; diff --git a/js/mapping.js b/js/mapping.js index 4894409..220dbbc 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -13,6 +13,19 @@ export const MODE_TRIPLANAR = 5; export const MODE_CUBIC = 6; const TWO_PI = Math.PI * 2; +const CUBIC_AXIS_EPSILON = 1e-4; + +export function getDominantCubicAxis(normal) { + const ax = Math.abs(normal.x); + const ay = Math.abs(normal.y); + const az = Math.abs(normal.z); + + // Treat near-ties as an intentional tie so 45° faces pick one stable axis + // instead of flipping projection due to tiny normal jitter between triangles. + if (ax >= ay - CUBIC_AXIS_EPSILON && ax >= az - CUBIC_AXIS_EPSILON) return 'x'; + if (ay >= az - CUBIC_AXIS_EPSILON) return 'y'; + return 'z'; +} /** * Compute normalised UV coordinates [0, 1) (tiling) for a vertex. @@ -54,28 +67,42 @@ export function computeUV(pos, normal, mode, settings, bounds) { } case MODE_CYLINDRICAL: { - // Cylindrical around Z axis with automatic caps. - // - // Side: V arc-length-normalised by circumference C = 2πr so that - // scaleU = scaleV gives un-stretched square texels on the surface. - // - // Cap (|normalZ| > 0.5): planar XY centred on the axis, scaled to the - // diameter so one tile covers the full cap disc. + // 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. const r = Math.max(size.x, size.y) * 0.5; const C = TWO_PI * Math.max(r, 1e-6); const rx = pos.x - center.x; const ry = pos.y - center.y; - if (Math.abs(normal.z) > 0.7) { - // Cap face — normalise by C so one tile = same world size as on the side - u = rx / C + 0.5; - v = ry / C + 0.5; - } else { - // Side face - const theta = Math.atan2(ry, rx); - u = (theta / TWO_PI) + 0.5; - v = (pos.z - min.z) / C; + const blend = settings.mappingBlend ?? 0.0; + const theta = Math.atan2(ry, rx); + const uSide = (theta / TWO_PI) + 0.5; + const vSide = (pos.z - min.z) / C; + if (blend <= 0.001) { + return applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); } - break; + const blendHalf = blend * 0.20; + const absnz = Math.abs(normal.z); + const capW = Math.max(0, Math.min(1, (absnz - (0.7 - blendHalf)) / (2 * blendHalf + 1e-6))); + if (capW <= 0) { + return applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + } + const uCap = rx / C + 0.5; + const vCap = ry / C + 0.5; + if (capW >= 1) { + return applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); + } + // Return two separate samples so displacement.js blends the *heights*, + // not the UV coordinates (blending atan2-based and planar UVs directly + // produces garbage values in the transition zone). + const tSide = applyTransform(uSide, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); + const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); + return { + triplanar: true, + samples: [ + { u: tSide.u, v: tSide.v, w: 1 - capW }, + { u: tCap.u, v: tCap.v, w: capW }, + ], + }; } case MODE_SPHERICAL: { @@ -91,19 +118,20 @@ export function computeUV(pos, normal, mode, settings, bounds) { } case MODE_CUBIC: { - const ax = Math.abs(normal.x); - const ay = Math.abs(normal.y); - const az = Math.abs(normal.z); let uRaw, vRaw; - if (ax >= ay && ax >= az) { - uRaw = (pos.y - min.y) / md; - vRaw = (pos.z - min.z) / md; - } else if (ay >= ax && ay >= az) { - uRaw = (pos.x - min.x) / md; - vRaw = (pos.z - min.z) / md; - } else { - uRaw = (pos.x - min.x) / md; - vRaw = (pos.y - min.y) / md; + 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); } diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 22f9bea..1752234 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -26,11 +26,16 @@ const vertexShader = /* glsl */` varying vec3 vNormal; // view-space normal → lighting void main() { - vModelPos = position; - vModelNormal = normalize(normal); + vModelPos = position; + // Guard against degenerate zero-length normals (non-manifold / multi-body STLs + // can produce averaged-to-zero normals at shared vertices between opposing bodies). + // normalize(vec3(0)) is undefined in GLSL and produces NaN on most GPUs, + // which then turns the entire fragment black. + vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0); + vModelNormal = safeN; vec4 mvPos = modelViewMatrix * vec4(position, 1.0); vViewPos = mvPos.xyz; - vNormal = normalize(normalMatrix * normal); + vNormal = normalize(normalMatrix * safeN); gl_Position = projectionMatrix * mvPos; } `; @@ -49,6 +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) varying vec3 vModelPos; varying vec3 vModelNormal; @@ -57,6 +63,14 @@ const fragmentShader = /* glsl */` const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; + const float CUBIC_AXIS_EPSILON = 1e-4; + + int dominantCubicAxis(vec3 n) { + vec3 absN = abs(n); + if (absN.x >= absN.y - CUBIC_AXIS_EPSILON && absN.x >= absN.z - CUBIC_AXIS_EPSILON) return 0; + if (absN.y >= absN.z - CUBIC_AXIS_EPSILON) return 1; + return 2; + } // Sample after applying scale + tiling float sampleMap(vec2 rawUV) { @@ -73,11 +87,20 @@ const fragmentShader = /* glsl */` // Uses vModelPos / vModelNormal (model-space) so UV is stable as the camera orbits. float getHeight() { vec3 pos = vModelPos; - vec3 MN = vModelNormal; // model-space normal + vec3 MN = vModelNormal; // smooth interpolated normal → shading only vec3 rel = pos - boundsCenter; float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z)); float md = max(maxDim, 1e-4); + // Face-stable projection normal: cross product of screen-space position + // derivatives is CONSTANT within a triangle (unlike the interpolated + // vModelNormal), eliminating within-face texture z-fighting at seam + // boundaries in cubic / triplanar mapping. Falls back to MN if degenerate. + vec3 _dpx = dFdx(vModelPos); + vec3 _dpy = dFdy(vModelPos); + vec3 _fN = cross(_dpx, _dpy); + vec3 PN = length(_fN) > 1e-10 ? normalize(_fN) : MN; + if (mappingMode == 0) { return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); @@ -88,27 +111,16 @@ const fragmentShader = /* glsl */` return sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); } else if (mappingMode == 3) { - // Cylindrical around Z axis (Z is up) with automatic caps. - // - // Side: V is arc-length-normalised (divided by circumference C = 2πr) - // so that scaleU = scaleV gives square, un-stretched texels on the surface. - // - // Cap (|normalZ| > 0.5): planar XY centred on the cylinder axis, one tile - // fills the diameter × diameter square so the disc looks fully textured. + // Cylindrical around Z axis (Z is up) with blendable side↔cap transition. float r = max(boundsSize.x, boundsSize.y) * 0.5; float C = TWO_PI * max(r, 1e-4); - if (abs(vModelNormal.z) > 0.7) { - // Cap face — normalise by C so one tile = same world size as on the side - return sampleMap(vec2( - rel.x / C + 0.5, - rel.y / C + 0.5 - )); - } - // Side face - return sampleMap(vec2( - atan(rel.y, rel.x) / TWO_PI + 0.5, - (pos.z - boundsMin.z) / C - )); + float hSide = sampleMap(vec2(atan(rel.y, rel.x) / TWO_PI + 0.5, + (pos.z - boundsMin.z) / C)); + if (mappingBlend < 0.001) return hSide; + float blendHalf = mappingBlend * 0.20; + float capW = smoothstep(0.7 - blendHalf, 0.7 + blendHalf, abs(vModelNormal.z)); + float hCap = sampleMap(vec2(rel.x / C + 0.5, rel.y / C + 0.5)); + return mix(hSide, hCap, capW); } else if (mappingMode == 4) { // Spherical — Z is up @@ -118,8 +130,8 @@ const fragmentShader = /* glsl */` return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI)); } else if (mappingMode == 5) { - // Triplanar – smooth blend using model-space normal (stable regardless of camera) - vec3 blend = abs(MN); + // Triplanar – smooth blend using face-stable projection normal (constant per triangle) + vec3 blend = abs(PN); blend = pow(blend, vec3(4.0)); blend /= dot(blend, vec3(1.0)) + 1e-4; @@ -130,22 +142,22 @@ const fragmentShader = /* glsl */` return hXY * blend.z + hXZ * blend.y + hYZ * blend.x; } else { - // Cubic (box) – hard-edge face selection using model-space normal - // Picks the single planar projection whose axis is most aligned with the face normal. - vec3 absN = abs(MN); - if (absN.x >= absN.y && absN.x >= absN.z) { - return sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); - } else if (absN.y >= absN.x && absN.y >= absN.z) { - return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); - } else { - return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); - } + // Cubic (box) – always pick exactly one projection per triangle. + 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; } } void main() { - vec3 N = normalize(vNormal); + // Flip normal for back faces so flipped-winding geometry still lights correctly. + vec3 N = normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0); float h = getHeight(); + // ── Surface angle masking (FDM: suppress texture on near-horizontal faces) ──── // Use a 15° smoothstep fade above the threshold so the bump tapers gradually // into the masked region rather than cutting off abruptly at the boundary edge. @@ -157,12 +169,11 @@ const fragmentShader = /* glsl */` if (vModelNormal.z >= 0.0 && topAngleLimit >= 1.0) maskBlend = min(maskBlend, smoothstep(topAngleLimit, topAngleLimit + FADE, surfaceAngle)); h = mix(0.5, h, maskBlend); // blend toward neutral grey (zero-gradient → no bump) + // ── Bump mapping via screen-space height derivatives ────────────────── - // dFdx/dFdy give the height change per screen pixel → height gradient float dhx = dFdx(h); float dhy = dFdy(h); - // Screen-space surface tangent / bitangent, projected onto the surface plane vec3 dp1 = dFdx(vViewPos); vec3 dp2 = dFdy(vViewPos); @@ -173,19 +184,16 @@ const fragmentShader = /* glsl */` T = lenT > 1e-5 ? T / lenT : vec3(1.0, 0.0, 0.0); B = lenB > 1e-5 ? B / lenB : vec3(0.0, 1.0, 0.0); - // Normalise bump strength by position derivative so the effect is - // independent of zoom level / mesh scale. + // Bump strength normalised by screen-space position derivative so + // the effect is independent of zoom level. float posScale = max(length(dp1) + length(dp2), 1e-6); - float bumpStr = amplitude * 1.2 / posScale; + float bumpStr = amplitude * 6.0 / posScale; - vec3 bumpN = normalize(N - bumpStr * (dhx * T + dhy * B)); + vec3 bumpVec = N - bumpStr * (dhx * T + dhy * B); + vec3 bumpN = length(bumpVec) > 1e-6 ? normalize(bumpVec) : N; // ── Shading ─────────────────────────────────────────────────────────── - // Base colour: cool-to-warm tint driven by the displacement height value - // so the texture pattern is clearly visible even without bump lighting. - vec3 lo = vec3(0.18, 0.20, 0.35); - vec3 hi = vec3(0.90, 0.84, 0.68); - vec3 baseColor = mix(lo, hi, h); + vec3 baseColor = mix(vec3(0.50, 0.50, 0.50), vec3(0.22, 0.68, 0.68), maskBlend); vec3 L1 = normalize(vec3( 0.5, 0.8, 1.0)); vec3 L2 = normalize(vec3(-0.5, -0.2, -0.6)); @@ -195,11 +203,11 @@ const fragmentShader = /* glsl */` float diff2 = max(dot(bumpN, L2), 0.0) * 0.35; vec3 H1 = normalize(L1 + V); - float spec = pow(max(dot(bumpN, H1), 0.0), 48.0) * 0.55; + float spec = pow(max(dot(bumpN, H1), 0.0), 64.0) * 0.60; - vec3 color = baseColor * 0.60 // strong ambient — texture always visible - + baseColor * diff1 * vec3(1.00, 0.97, 0.90) * 0.45 // key light - + baseColor * diff2 * vec3(0.40, 0.50, 0.80) * 0.20 // fill light + vec3 color = baseColor * 0.55 // ambient + + baseColor * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 // key light + + baseColor * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 // warm fill + vec3(spec); // specular gl_FragColor = vec4(color, 1.0); @@ -243,6 +251,7 @@ export function updateMaterial(material, displacementTexture, settings) { } u.bottomAngleLimit.value = settings.bottomAngleLimit ?? 5.0; u.topAngleLimit.value = settings.topAngleLimit ?? 0.0; + u.mappingBlend.value = settings.mappingBlend ?? 0.0; } // ── Internal ────────────────────────────────────────────────────────────────── @@ -265,6 +274,7 @@ function buildUniforms(tex, settings) { boundsCenter: { value: b.center.clone() }, bottomAngleLimit: { value: settings.bottomAngleLimit ?? 5.0 }, topAngleLimit: { value: settings.topAngleLimit ?? 0.0 }, + mappingBlend: { value: settings.mappingBlend ?? 0.0 }, }; } diff --git a/style.css b/style.css index cc1fdcc..340389b 100644 --- a/style.css +++ b/style.css @@ -604,6 +604,29 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } .tri-limit-warning.hidden { display: none; } +/* ── Amplitude overlap warning ────────────────────────────────────────────────────────────── */ +.amplitude-warning { + background: color-mix(in srgb, var(--danger) 12%, var(--surface2)); + border: 1px solid var(--danger); + color: var(--danger); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 11px; + line-height: 1.4; + margin-top: 4px; + margin-bottom: 2px; +} + +.amplitude-warning.hidden { display: none; } + +/* Red highlight on slider + number input when amplitude is dangerously high */ +#amplitude.amp-danger, #amplitude-val.amp-danger { + accent-color: var(--danger); + border-color: var(--danger); + outline: 1px solid var(--danger); + color: var(--danger); +} + /* ── Surface Exclusions panel ────────────────────────────────────────────── */ /* Tool button row */