From 981a72af4d53c28c58e63f3708385b49a531230c Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Thu, 19 Mar 2026 21:00:33 +0100 Subject: [PATCH] Added a 3D Preview --- index.html | 8 ++ js/displacement.js | 2 +- js/i18n.js | 10 +++ js/main.js | 161 ++++++++++++++++++++++++++++++++++++- js/previewMaterial.js | 183 ++++++++++++++++++++++++------------------ js/viewer.js | 20 +++++ 6 files changed, 302 insertions(+), 82 deletions(-) diff --git a/index.html b/index.html index 19d61fd..ee6d997 100644 --- a/index.html +++ b/index.html @@ -161,6 +161,14 @@ Symmetric displacement ⓘ +
+ +
diff --git a/js/displacement.js b/js/displacement.js index 7c1f23b..5429e7b 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -251,7 +251,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k); const mf = maskedFracMap.get(k) || [0, 1]; const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0; - const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) * 2.0 : grey; + const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey; const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude; const newX = tmpPos.x + sn[0] * disp; diff --git a/js/i18n.js b/js/i18n.js index ce55da8..0191f94 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -96,6 +96,11 @@ export const TRANSLATIONS = { 'labels.symmetricDisplacement': 'Symmetric displacement \u24d8', 'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.', + // Displacement preview + 'labels.displacementPreview': '3D Preview \u24d8', + 'tooltips.displacementPreview': 'Subdivides the mesh and displaces vertices in real-time so you can judge the actual depth. GPU-intensive on complex models.', + 'progress.subdividingPreview': 'Preparing preview\u2026', + // Amplitude overlap warning 'warnings.amplitudeOverlap': '\u26a0 Amplitude exceeds 10% of the smallest model dimension \u2014 geometry overlaps may occur in the exported STL.', @@ -229,6 +234,11 @@ export const TRANSLATIONS = { 'labels.symmetricDisplacement': 'Symmetrische Verschiebung \u24d8', 'tooltips.symmetricDisplacement':'Wenn aktiv: 50% Grau = keine Verschiebung; Weiß nach außen, Schwarz nach innen. H\u00e4lt das Volumen des Teils in etwa konstant.', + // Displacement preview + 'labels.displacementPreview': '3D-Vorschau \u24d8', + 'tooltips.displacementPreview': 'Unterteilt das Netz und verschiebt Punkte in Echtzeit, damit die tats\u00e4chliche Tiefe sichtbar wird. GPU-intensiv bei komplexen Modellen.', + 'progress.subdividingPreview': 'Vorschau wird vorbereitet\u2026', + // Amplitude overlap warning 'warnings.amplitudeOverlap': '\u26a0 Amplitude \u00fcberschreitet 10% der kleinsten Modellabmessung \u2014 beim Export k\u00f6nnen Geometrie\u00fcberschneidungen auftreten.', diff --git a/js/main.js b/js/main.js index 31c22fc..560ee93 100644 --- a/js/main.js +++ b/js/main.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { initViewer, loadGeometry, setMeshMaterial, setWireframe, +import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js'; @@ -53,8 +53,13 @@ const settings = { mappingBlend: 1, seamBandWidth: 0.5, symmetricDisplacement: false, + useDisplacement: false, }; +// ── Displacement preview state ──────────────────────────────────────────────── +let dispPreviewGeometry = null; // subdivided geometry with smoothNormal attribute +let dispPreviewBusy = false; // true while async subdivision is running + // ── DOM refs ────────────────────────────────────────────────────────────────── const canvas = document.getElementById('viewport'); @@ -104,6 +109,7 @@ 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 dispPreviewToggle = document.getElementById('displacement-preview'); // ── Exclusion panel DOM refs ────────────────────────────────────────────────── const exclBrushBtn = document.getElementById('excl-brush-btn'); @@ -322,6 +328,10 @@ function wireEvents() { updatePreview(); }); + dispPreviewToggle.addEventListener('change', () => { + toggleDisplacementPreview(dispPreviewToggle.checked); + }); + // ── Export ── exportBtn.addEventListener('click', () => { if (sessionStorage.getItem('stlt-no-sponsor') === '1') { @@ -695,6 +705,11 @@ function loadDefaultCube() { loadGeometry(geo); dropHint.classList.add('hidden'); + // Reset displacement preview + if (dispPreviewGeometry) { dispPreviewGeometry.dispose(); dispPreviewGeometry = null; } + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + // Reset exclusion state excludedFaces = new Set(); exclusionTool = null; @@ -763,6 +778,11 @@ async function handleSTL(file) { loadGeometry(geometry); dropHint.classList.add('hidden'); + // Reset displacement preview for the new mesh + if (dispPreviewGeometry) { dispPreviewGeometry.dispose(); dispPreviewGeometry = null; } + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + // Reset exclusion state for the new mesh excludedFaces = new Set(); exclusionTool = null; @@ -842,9 +862,14 @@ function updatePreview() { return; } + // Choose geometry: subdivided preview (with smoothNormal attribute) or original + const activeGeo = (settings.useDisplacement && dispPreviewGeometry) + ? dispPreviewGeometry + : currentGeometry; + if (!previewMaterial) { previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); - loadGeometry(currentGeometry, previewMaterial); + loadGeometry(activeGeo, previewMaterial); } else { updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings); } @@ -852,6 +877,138 @@ function updatePreview() { exportBtn.disabled = false; } +// ── Displacement preview ────────────────────────────────────────────────────── + +/** + * Compute area-weighted smooth normals for a non-indexed geometry and store + * them as a `smoothNormal` vec3 attribute. Every copy of the same position + * gets the same averaged normal so vertex-shader displacement is watertight. + */ +function addSmoothNormals(geometry) { + const pos = geometry.attributes.position.array; + const count = geometry.attributes.position.count; + + const QUANT = 1e4; + const key = (x, y, z) => + `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; + + // Accumulate area-weighted face normals per unique position + const nrmMap = new Map(); + const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3(); + const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3(); + + for (let i = 0; i < count; i += 3) { + vA.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); + vB.set(pos[(i + 1) * 3], pos[(i + 1) * 3 + 1], pos[(i + 1) * 3 + 2]); + vC.set(pos[(i + 2) * 3], pos[(i + 2) * 3 + 1], pos[(i + 2) * 3 + 2]); + e1.subVectors(vB, vA); + e2.subVectors(vC, vA); + fn.crossVectors(e1, e2); // length = 2 × triangle area + const area = fn.length(); + if (area < 1e-12) continue; + fn.divideScalar(area); // unit face normal + for (const v of [vA, vB, vC]) { + const k = key(v.x, v.y, v.z); + const prev = nrmMap.get(k); + if (prev) { + prev[0] += fn.x * area; + prev[1] += fn.y * area; + prev[2] += fn.z * area; + } else { + nrmMap.set(k, [fn.x * area, fn.y * area, fn.z * area]); + } + } + } + + // Normalize accumulated normals + for (const n of nrmMap.values()) { + const len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + if (len > 1e-12) { n[0] /= len; n[1] /= len; n[2] /= len; } + } + + // Write smoothNormal attribute + const sn = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const k = key(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); + const n = nrmMap.get(k); + if (n) { sn[i * 3] = n[0]; sn[i * 3 + 1] = n[1]; sn[i * 3 + 2] = n[2]; } + else { sn[i * 3] = 0; sn[i * 3 + 1] = 0; sn[i * 3 + 2] = 1; } + } + geometry.setAttribute('smoothNormal', new THREE.Float32BufferAttribute(sn, 3)); +} + +/** + * Toggle displacement preview on/off. + * When enabled: subdivides the current geometry to a moderate resolution, + * computes smooth normals, and switches the viewer to the subdivided + * geometry with vertex-shader displacement. + * When disabled: reverts to the original geometry with bump-only preview. + */ +async function toggleDisplacementPreview(enable) { + settings.useDisplacement = enable; + + if (!enable) { + // Revert to original geometry with bump-only shading. + if (currentGeometry && previewMaterial) { + updateMaterial(previewMaterial, activeMapEntry?.texture, { ...settings, bounds: currentBounds }); + setMeshGeometry(currentGeometry); + } + // Dispose the subdivided preview geometry (no longer on the mesh) + if (dispPreviewGeometry) { + dispPreviewGeometry.dispose(); + dispPreviewGeometry = null; + } + return; + } + + // Need a model and texture to subdivide + if (!currentGeometry || !currentBounds || !activeMapEntry) { + dispPreviewToggle.checked = false; + settings.useDisplacement = false; + return; + } + + if (dispPreviewBusy) return; + dispPreviewBusy = true; + + try { + // Choose a preview edge length: coarser than export for performance. + // Target ~maxDim/80 so a 50 mm cube gets ~0.6 mm edges → ~100 k triangles. + const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z); + const previewEdge = Math.max(0.1, maxDim / 80); + + await yieldFrame(); + + const { geometry: subdivided } = await subdivide( + currentGeometry, previewEdge, null, null + ); + + addSmoothNormals(subdivided); + + // Dispose previous preview geometry if any + if (dispPreviewGeometry) dispPreviewGeometry.dispose(); + dispPreviewGeometry = subdivided; + + // Force material recreation so it binds the new geometry with smoothNormal + if (previewMaterial) { + previewMaterial.dispose(); + previewMaterial = null; + } + const fullSettings = { ...settings, bounds: currentBounds }; + previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings); + setMeshGeometry(dispPreviewGeometry); + setMeshMaterial(previewMaterial); + + + } catch (err) { + console.error('Displacement preview failed:', err); + dispPreviewToggle.checked = false; + settings.useDisplacement = false; + } finally { + dispPreviewBusy = false; + } +} + // ── Export pipeline ─────────────────────────────────────────────────────────── /** diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 14bab60..e4bbbb4 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -11,38 +11,17 @@ export const MODE_CUBIC = 6; // ── GLSL source ────────────────────────────────────────────────────────────── // -// Preview strategy: NO vertex displacement. -// All UV projection is done in the fragment shader so the underlying mesh -// geometry is never modified. The displacement map is visualised via -// per-fragment bump mapping (perturbing the shading normal from screen-space -// height derivatives). `amplitude` scales the bump intensity only. - -const vertexShader = /* glsl */` - precision highp float; - - varying vec3 vModelPos; // model-space position → UV computation in fragment - varying vec3 vModelNormal; // model-space normal → stable UV blending (triplanar/cubic) - varying vec3 vViewPos; // view-space position → TBN & specular - varying vec3 vNormal; // view-space normal → lighting - - void main() { - 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 * safeN); - gl_Position = projectionMatrix * mvPos; - } -`; - -const fragmentShader = /* glsl */` - precision highp float; +// Preview strategy, two modes: +// 1. Bump-only (default): UV projection & bump mapping in the fragment shader. +// The underlying geometry is never modified; amplitude scales bump intensity. +// 2. Displacement preview: The vertex shader samples the same displacement +// texture and physically moves each vertex along its smooth normal. +// Fragment shader adds reduced bump mapping for sub-vertex detail. +// +// The shared GLSL block below is included in BOTH shaders so UV math, +// projection modes, and texture sampling stay identical. +const sharedGLSL = /* glsl */` uniform sampler2D displacementMap; uniform int mappingMode; uniform vec2 scaleUV; @@ -52,16 +31,12 @@ const fragmentShader = /* glsl */` uniform vec3 boundsMin; uniform vec3 boundsSize; 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 - 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 - - varying vec3 vModelPos; - varying vec3 vModelNormal; - varying vec3 vViewPos; - varying vec3 vNormal; + uniform float bottomAngleLimit; + uniform float topAngleLimit; + uniform float mappingBlend; + uniform float seamBandWidth; + uniform int symmetricDisplacement; + uniform int useDisplacement; const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; @@ -108,7 +83,6 @@ const fragmentShader = /* glsl */` // Sample after applying scale + tiling float sampleMap(vec2 rawUV) { vec2 uv = rawUV / scaleUV + offsetUV; - // rotate around tile centre 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); @@ -116,24 +90,14 @@ const fragmentShader = /* glsl */` return texture2D(displacementMap, uv).r; } - // Height at this fragment for all projection modes. - // Uses vModelPos / vModelNormal (model-space) so UV is stable as the camera orbits. - float getHeight() { - vec3 pos = vModelPos; - vec3 MN = vModelNormal; // smooth interpolated normal → shading only + // Compute displacement height at a world-space point. + // projN = face-stable projection normal (for axis selection) + // blendN = smooth / interpolated normal (for blend weights) + float computeHeightAtPoint(vec3 pos, vec3 projN, vec3 blendN) { 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)); @@ -144,62 +108,119 @@ 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 blendable side↔cap transition. float r = max(boundsSize.x, boundsSize.y) * 0.5; float C = TWO_PI * max(r, 1e-4); 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 capW = smoothstep(0.7 - blendHalf, 0.7 + blendHalf, abs(blendN.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 float r = length(rel); float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0)); float theta = atan(rel.y, rel.x); return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI)); } else if (mappingMode == 5) { - // Triplanar – smooth blend using face-stable projection normal (constant per triangle) - vec3 blend = abs(PN); + 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)); - return hXY * blend.z + hXZ * blend.y + hYZ * blend.x; } else { - // 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)); - vec3 blendN = vModelNormal; - vec3 absFaceN = abs(PN); + vec3 bN = blendN; + vec3 absFaceN = abs(projN); 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); + float faceSecondary = absFaceN.x + absFaceN.y + absFaceN.z - facePrimary + - min(absFaceN.x, min(absFaceN.y, absFaceN.z)); + if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) bN = projN; + vec3 wts = cubicBlendWeights(bN); return hYZ * wts.x + hXZ * wts.y + hXY * wts.z; } } +`; + +const vertexShader = /* glsl */` + precision highp float; + ${sharedGLSL} + + attribute vec3 smoothNormal; + + varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment + varying vec3 vModelNormal; // model-space face normal → stable UV blending + varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular + varying vec3 vNormal; // view-space normal → lighting + + void main() { + vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0); + vec3 pos = position; + + if (useDisplacement == 1) { + // Sample displacement texture using the same UV math as the fragment shader + float h = computeHeightAtPoint(position, safeN, safeN); + if (symmetricDisplacement == 1) h = h - 0.5; + + // Surface angle masking (same logic as fragment shader) + float surfaceAngle = degrees(acos(clamp(abs(safeN.z), 0.0, 1.0))); + float maskBlend = 1.0; + float FADE = 15.0; + if (safeN.z < 0.0 && bottomAngleLimit >= 1.0) + maskBlend = min(maskBlend, smoothstep(bottomAngleLimit, bottomAngleLimit + FADE, surfaceAngle)); + if (safeN.z >= 0.0 && topAngleLimit >= 1.0) + maskBlend = min(maskBlend, smoothstep(topAngleLimit, topAngleLimit + FADE, surfaceAngle)); + h = mix(0.0, h, maskBlend); + + // Displace along smooth normal so all copies of the same position + // arrive at the same point (watertight, no cracks). + vec3 sN = length(smoothNormal) > 1e-6 ? normalize(smoothNormal) : safeN; + pos = position + sN * h * amplitude; + } + + // Always pass the ORIGINAL position for UV computation in the fragment shader. + vModelPos = position; + vModelNormal = safeN; + vec4 mvPos = modelViewMatrix * vec4(pos, 1.0); + vViewPos = mvPos.xyz; + vNormal = normalize(normalMatrix * safeN); + gl_Position = projectionMatrix * mvPos; + } +`; + +const fragmentShader = /* glsl */` + precision highp float; + ${sharedGLSL} + + varying vec3 vModelPos; + varying vec3 vModelNormal; + varying vec3 vViewPos; + varying vec3 vNormal; + + // Fragment-only wrapper: compute face-stable projection normal via dFdx + // then delegate to the shared height function. + float getHeight() { + vec3 _dpx = dFdx(vModelPos); + vec3 _dpy = dFdy(vModelPos); + vec3 _fN = cross(_dpx, _dpy); + vec3 PN = length(_fN) > 1e-10 ? normalize(_fN) : vModelNormal; + return computeHeightAtPoint(vModelPos, PN, vModelNormal); + } void main() { // 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(); - if (symmetricDisplacement == 1) h = h * 2.0 - 1.0; // remap [0,1]→[-1,1]: 0.5 grey = zero + if (symmetricDisplacement == 1) h = h - 0.5; - // ── 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. + // ── Surface angle masking ───────────────────────────────────────────── float surfaceAngle = degrees(acos(clamp(abs(vModelNormal.z), 0.0, 1.0))); float maskBlend = 1.0; float FADE = 15.0; @@ -207,7 +228,7 @@ const fragmentShader = /* glsl */` maskBlend = min(maskBlend, smoothstep(bottomAngleLimit, bottomAngleLimit + FADE, surfaceAngle)); if (vModelNormal.z >= 0.0 && topAngleLimit >= 1.0) maskBlend = min(maskBlend, smoothstep(topAngleLimit, topAngleLimit + FADE, surfaceAngle)); - h = mix(0.0, h, maskBlend); // blend toward neutral (zero-gradient → no bump) + h = mix(0.0, h, maskBlend); // ── Bump mapping via screen-space height derivatives ────────────────── float dhx = dFdx(h); @@ -223,10 +244,12 @@ 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); - // Bump strength normalised by screen-space position derivative so - // the effect is independent of zoom level. + // When vertex displacement is active, reduce bump strength: the macro shape + // is already physical; bump only adds sub-vertex fine detail. float posScale = max(length(dp1) + length(dp2), 1e-6); - float bumpStr = amplitude * 6.0 / posScale; + float bumpStr = useDisplacement == 1 + ? amplitude * 2.0 / posScale + : amplitude * 6.0 / posScale; vec3 bumpVec = N - bumpStr * (dhx * T + dhy * B); vec3 bumpN = length(bumpVec) > 1e-6 ? normalize(bumpVec) : N; @@ -293,6 +316,7 @@ export function updateMaterial(material, displacementTexture, settings) { u.mappingBlend.value = settings.mappingBlend ?? 0.0; u.seamBandWidth.value = settings.seamBandWidth ?? 0.35; u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0; + u.useDisplacement.value = settings.useDisplacement ? 1 : 0; } // ── Internal ────────────────────────────────────────────────────────────────── @@ -318,6 +342,7 @@ function buildUniforms(tex, settings) { mappingBlend: { value: settings.mappingBlend ?? 0.0 }, seamBandWidth: { value: settings.seamBandWidth ?? 0.35 }, symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 }, + useDisplacement: { value: settings.useDisplacement ? 1 : 0 }, }; } diff --git a/js/viewer.js b/js/viewer.js index 48eb4cf..c1918fe 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -300,6 +300,26 @@ export function setMeshMaterial(material) { }); } +/** + * Swap only the geometry on the current mesh, keeping material and camera. + * Rebuilds wireframe if visible. Does NOT reset camera or grid. + * The caller is responsible for disposing old geometry if needed. + * @param {THREE.BufferGeometry} geometry + */ +export function setMeshGeometry(geometry) { + if (!currentMesh) return; + if (!geometry.attributes.normal) geometry.computeVertexNormals(); + currentMesh.geometry = geometry; + // Rebuild wireframe overlay to match the new geometry + if (wireframeLines) { + meshGroup.remove(wireframeLines); + wireframeLines.geometry.dispose(); + wireframeLines.material.dispose(); + wireframeLines = null; + } + if (wireframeVisible) _buildWireframe(geometry); +} + /** * Get the grid object so callers can adjust position. */