mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
Added a 3D Preview
This commit is contained in:
@@ -161,6 +161,14 @@
|
|||||||
<span data-i18n="labels.symmetricDisplacement">Symmetric displacement ⓘ</span>
|
<span data-i18n="labels.symmetricDisplacement">Symmetric displacement ⓘ</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="checkbox-label" for="displacement-preview"
|
||||||
|
data-i18n-title="tooltips.displacementPreview"
|
||||||
|
title="Subdivides the mesh and displaces vertices in real-time so you can judge the actual depth. GPU-intensive on complex models.">
|
||||||
|
<input type="checkbox" id="displacement-preview" />
|
||||||
|
<span data-i18n="labels.displacementPreview">3D Preview ⓘ</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Transform -->
|
<!-- Transform -->
|
||||||
|
|||||||
+1
-1
@@ -251,7 +251,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k);
|
const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k);
|
||||||
const mf = maskedFracMap.get(k) || [0, 1];
|
const mf = maskedFracMap.get(k) || [0, 1];
|
||||||
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
|
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 disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
|
||||||
|
|
||||||
const newX = tmpPos.x + sn[0] * disp;
|
const newX = tmpPos.x + sn[0] * disp;
|
||||||
|
|||||||
+10
@@ -96,6 +96,11 @@ export const TRANSLATIONS = {
|
|||||||
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
|
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
|
||||||
'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.',
|
'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
|
// Amplitude overlap warning
|
||||||
'warnings.amplitudeOverlap': '\u26a0 Amplitude exceeds 10% of the smallest model dimension \u2014 geometry overlaps may occur in the exported STL.',
|
'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',
|
'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.',
|
'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
|
// Amplitude overlap warning
|
||||||
'warnings.amplitudeOverlap': '\u26a0 Amplitude \u00fcberschreitet 10% der kleinsten Modellabmessung \u2014 beim Export k\u00f6nnen Geometrie\u00fcberschneidungen auftreten.',
|
'warnings.amplitudeOverlap': '\u26a0 Amplitude \u00fcberschreitet 10% der kleinsten Modellabmessung \u2014 beim Export k\u00f6nnen Geometrie\u00fcberschneidungen auftreten.',
|
||||||
|
|
||||||
|
|||||||
+159
-2
@@ -1,5 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { initViewer, loadGeometry, setMeshMaterial, setWireframe,
|
import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe,
|
||||||
getControls, getCamera, getCurrentMesh,
|
getControls, getCamera, getCurrentMesh,
|
||||||
setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js';
|
setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js';
|
||||||
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
|
||||||
@@ -53,8 +53,13 @@ const settings = {
|
|||||||
mappingBlend: 1,
|
mappingBlend: 1,
|
||||||
seamBandWidth: 0.5,
|
seamBandWidth: 0.5,
|
||||||
symmetricDisplacement: false,
|
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 ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const canvas = document.getElementById('viewport');
|
const canvas = document.getElementById('viewport');
|
||||||
@@ -104,6 +109,7 @@ const seamBlendVal = document.getElementById('seam-blend-val');
|
|||||||
const seamBandWidthSlider = document.getElementById('seam-band-width');
|
const seamBandWidthSlider = document.getElementById('seam-band-width');
|
||||||
const seamBandWidthVal = document.getElementById('seam-band-width-val');
|
const seamBandWidthVal = document.getElementById('seam-band-width-val');
|
||||||
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
||||||
|
const dispPreviewToggle = document.getElementById('displacement-preview');
|
||||||
|
|
||||||
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
|
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
|
||||||
const exclBrushBtn = document.getElementById('excl-brush-btn');
|
const exclBrushBtn = document.getElementById('excl-brush-btn');
|
||||||
@@ -322,6 +328,10 @@ function wireEvents() {
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dispPreviewToggle.addEventListener('change', () => {
|
||||||
|
toggleDisplacementPreview(dispPreviewToggle.checked);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Export ──
|
// ── Export ──
|
||||||
exportBtn.addEventListener('click', () => {
|
exportBtn.addEventListener('click', () => {
|
||||||
if (sessionStorage.getItem('stlt-no-sponsor') === '1') {
|
if (sessionStorage.getItem('stlt-no-sponsor') === '1') {
|
||||||
@@ -695,6 +705,11 @@ function loadDefaultCube() {
|
|||||||
loadGeometry(geo);
|
loadGeometry(geo);
|
||||||
dropHint.classList.add('hidden');
|
dropHint.classList.add('hidden');
|
||||||
|
|
||||||
|
// Reset displacement preview
|
||||||
|
if (dispPreviewGeometry) { dispPreviewGeometry.dispose(); dispPreviewGeometry = null; }
|
||||||
|
settings.useDisplacement = false;
|
||||||
|
dispPreviewToggle.checked = false;
|
||||||
|
|
||||||
// Reset exclusion state
|
// Reset exclusion state
|
||||||
excludedFaces = new Set();
|
excludedFaces = new Set();
|
||||||
exclusionTool = null;
|
exclusionTool = null;
|
||||||
@@ -763,6 +778,11 @@ async function handleSTL(file) {
|
|||||||
loadGeometry(geometry);
|
loadGeometry(geometry);
|
||||||
dropHint.classList.add('hidden');
|
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
|
// Reset exclusion state for the new mesh
|
||||||
excludedFaces = new Set();
|
excludedFaces = new Set();
|
||||||
exclusionTool = null;
|
exclusionTool = null;
|
||||||
@@ -842,9 +862,14 @@ function updatePreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Choose geometry: subdivided preview (with smoothNormal attribute) or original
|
||||||
|
const activeGeo = (settings.useDisplacement && dispPreviewGeometry)
|
||||||
|
? dispPreviewGeometry
|
||||||
|
: currentGeometry;
|
||||||
|
|
||||||
if (!previewMaterial) {
|
if (!previewMaterial) {
|
||||||
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings);
|
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings);
|
||||||
loadGeometry(currentGeometry, previewMaterial);
|
loadGeometry(activeGeo, previewMaterial);
|
||||||
} else {
|
} else {
|
||||||
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings);
|
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings);
|
||||||
}
|
}
|
||||||
@@ -852,6 +877,138 @@ function updatePreview() {
|
|||||||
exportBtn.disabled = false;
|
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 ───────────────────────────────────────────────────────────
|
// ── Export pipeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+104
-79
@@ -11,38 +11,17 @@ export const MODE_CUBIC = 6;
|
|||||||
|
|
||||||
// ── GLSL source ──────────────────────────────────────────────────────────────
|
// ── GLSL source ──────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Preview strategy: NO vertex displacement.
|
// Preview strategy, two modes:
|
||||||
// All UV projection is done in the fragment shader so the underlying mesh
|
// 1. Bump-only (default): UV projection & bump mapping in the fragment shader.
|
||||||
// geometry is never modified. The displacement map is visualised via
|
// The underlying geometry is never modified; amplitude scales bump intensity.
|
||||||
// per-fragment bump mapping (perturbing the shading normal from screen-space
|
// 2. Displacement preview: The vertex shader samples the same displacement
|
||||||
// height derivatives). `amplitude` scales the bump intensity only.
|
// texture and physically moves each vertex along its smooth normal.
|
||||||
|
// Fragment shader adds reduced bump mapping for sub-vertex detail.
|
||||||
const vertexShader = /* glsl */`
|
//
|
||||||
precision highp float;
|
// The shared GLSL block below is included in BOTH shaders so UV math,
|
||||||
|
// projection modes, and texture sampling stay identical.
|
||||||
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;
|
|
||||||
|
|
||||||
|
const sharedGLSL = /* glsl */`
|
||||||
uniform sampler2D displacementMap;
|
uniform sampler2D displacementMap;
|
||||||
uniform int mappingMode;
|
uniform int mappingMode;
|
||||||
uniform vec2 scaleUV;
|
uniform vec2 scaleUV;
|
||||||
@@ -52,16 +31,12 @@ const fragmentShader = /* glsl */`
|
|||||||
uniform vec3 boundsMin;
|
uniform vec3 boundsMin;
|
||||||
uniform vec3 boundsSize;
|
uniform vec3 boundsSize;
|
||||||
uniform vec3 boundsCenter;
|
uniform vec3 boundsCenter;
|
||||||
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
|
uniform float bottomAngleLimit;
|
||||||
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
|
uniform float topAngleLimit;
|
||||||
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended
|
uniform float mappingBlend;
|
||||||
uniform float seamBandWidth; // width of the blend zone near cube-face seams
|
uniform float seamBandWidth;
|
||||||
uniform int symmetricDisplacement; // 1 = remap [0,1]→[-1,1] so 50% grey = no disp
|
uniform int symmetricDisplacement;
|
||||||
|
uniform int useDisplacement;
|
||||||
varying vec3 vModelPos;
|
|
||||||
varying vec3 vModelNormal;
|
|
||||||
varying vec3 vViewPos;
|
|
||||||
varying vec3 vNormal;
|
|
||||||
|
|
||||||
const float PI = 3.14159265358979;
|
const float PI = 3.14159265358979;
|
||||||
const float TWO_PI = 6.28318530717959;
|
const float TWO_PI = 6.28318530717959;
|
||||||
@@ -108,7 +83,6 @@ const fragmentShader = /* glsl */`
|
|||||||
// Sample after applying scale + tiling
|
// Sample after applying scale + tiling
|
||||||
float sampleMap(vec2 rawUV) {
|
float sampleMap(vec2 rawUV) {
|
||||||
vec2 uv = rawUV / scaleUV + offsetUV;
|
vec2 uv = rawUV / scaleUV + offsetUV;
|
||||||
// rotate around tile centre
|
|
||||||
float c = cos(rotation); float s = sin(rotation);
|
float c = cos(rotation); float s = sin(rotation);
|
||||||
uv -= 0.5;
|
uv -= 0.5;
|
||||||
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
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;
|
return texture2D(displacementMap, uv).r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Height at this fragment for all projection modes.
|
// Compute displacement height at a world-space point.
|
||||||
// Uses vModelPos / vModelNormal (model-space) so UV is stable as the camera orbits.
|
// projN = face-stable projection normal (for axis selection)
|
||||||
float getHeight() {
|
// blendN = smooth / interpolated normal (for blend weights)
|
||||||
vec3 pos = vModelPos;
|
float computeHeightAtPoint(vec3 pos, vec3 projN, vec3 blendN) {
|
||||||
vec3 MN = vModelNormal; // smooth interpolated normal → shading only
|
|
||||||
vec3 rel = pos - boundsCenter;
|
vec3 rel = pos - boundsCenter;
|
||||||
float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z));
|
float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z));
|
||||||
float md = max(maxDim, 1e-4);
|
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) {
|
if (mappingMode == 0) {
|
||||||
return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
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));
|
return sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
|
||||||
|
|
||||||
} else if (mappingMode == 3) {
|
} 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 r = max(boundsSize.x, boundsSize.y) * 0.5;
|
||||||
float C = TWO_PI * max(r, 1e-4);
|
float C = TWO_PI * max(r, 1e-4);
|
||||||
float hSide = sampleMap(vec2(atan(rel.y, rel.x) / TWO_PI + 0.5,
|
float hSide = sampleMap(vec2(atan(rel.y, rel.x) / TWO_PI + 0.5,
|
||||||
(pos.z - boundsMin.z) / C));
|
(pos.z - boundsMin.z) / C));
|
||||||
if (mappingBlend < 0.001) return hSide;
|
if (mappingBlend < 0.001) return hSide;
|
||||||
float blendHalf = mappingBlend * 0.20;
|
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));
|
float hCap = sampleMap(vec2(rel.x / C + 0.5, rel.y / C + 0.5));
|
||||||
return mix(hSide, hCap, capW);
|
return mix(hSide, hCap, capW);
|
||||||
|
|
||||||
} else if (mappingMode == 4) {
|
} else if (mappingMode == 4) {
|
||||||
// Spherical — Z is up
|
|
||||||
float r = length(rel);
|
float r = length(rel);
|
||||||
float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0));
|
float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0));
|
||||||
float theta = atan(rel.y, rel.x);
|
float theta = atan(rel.y, rel.x);
|
||||||
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
||||||
|
|
||||||
} else if (mappingMode == 5) {
|
} else if (mappingMode == 5) {
|
||||||
// Triplanar – smooth blend using face-stable projection normal (constant per triangle)
|
vec3 blend = abs(projN);
|
||||||
vec3 blend = abs(PN);
|
|
||||||
blend = pow(blend, vec3(4.0));
|
blend = pow(blend, vec3(4.0));
|
||||||
blend /= dot(blend, vec3(1.0)) + 1e-4;
|
blend /= dot(blend, vec3(1.0)) + 1e-4;
|
||||||
|
|
||||||
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
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 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));
|
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
|
||||||
|
|
||||||
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
|
||||||
|
|
||||||
} else {
|
} 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 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 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));
|
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
|
||||||
vec3 blendN = vModelNormal;
|
vec3 bN = blendN;
|
||||||
vec3 absFaceN = abs(PN);
|
vec3 absFaceN = abs(projN);
|
||||||
float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z));
|
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));
|
float faceSecondary = absFaceN.x + absFaceN.y + absFaceN.z - facePrimary
|
||||||
if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) blendN = PN;
|
- min(absFaceN.x, min(absFaceN.y, absFaceN.z));
|
||||||
vec3 wts = cubicBlendWeights(blendN);
|
if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) bN = projN;
|
||||||
|
vec3 wts = cubicBlendWeights(bN);
|
||||||
return hYZ * wts.x + hXZ * wts.y + hXY * wts.z;
|
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() {
|
void main() {
|
||||||
// Flip normal for back faces so flipped-winding geometry still lights correctly.
|
// Flip normal for back faces so flipped-winding geometry still lights correctly.
|
||||||
vec3 N = normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0);
|
vec3 N = normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0);
|
||||||
float h = getHeight();
|
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) ────
|
// ── Surface angle masking ─────────────────────────────────────────────
|
||||||
// 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.
|
|
||||||
float surfaceAngle = degrees(acos(clamp(abs(vModelNormal.z), 0.0, 1.0)));
|
float surfaceAngle = degrees(acos(clamp(abs(vModelNormal.z), 0.0, 1.0)));
|
||||||
float maskBlend = 1.0;
|
float maskBlend = 1.0;
|
||||||
float FADE = 15.0;
|
float FADE = 15.0;
|
||||||
@@ -207,7 +228,7 @@ const fragmentShader = /* glsl */`
|
|||||||
maskBlend = min(maskBlend, smoothstep(bottomAngleLimit, bottomAngleLimit + FADE, surfaceAngle));
|
maskBlend = min(maskBlend, smoothstep(bottomAngleLimit, bottomAngleLimit + FADE, surfaceAngle));
|
||||||
if (vModelNormal.z >= 0.0 && topAngleLimit >= 1.0)
|
if (vModelNormal.z >= 0.0 && topAngleLimit >= 1.0)
|
||||||
maskBlend = min(maskBlend, smoothstep(topAngleLimit, topAngleLimit + FADE, surfaceAngle));
|
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 ──────────────────
|
// ── Bump mapping via screen-space height derivatives ──────────────────
|
||||||
float dhx = dFdx(h);
|
float dhx = dFdx(h);
|
||||||
@@ -223,10 +244,12 @@ const fragmentShader = /* glsl */`
|
|||||||
T = lenT > 1e-5 ? T / lenT : vec3(1.0, 0.0, 0.0);
|
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);
|
B = lenB > 1e-5 ? B / lenB : vec3(0.0, 1.0, 0.0);
|
||||||
|
|
||||||
// Bump strength normalised by screen-space position derivative so
|
// When vertex displacement is active, reduce bump strength: the macro shape
|
||||||
// the effect is independent of zoom level.
|
// is already physical; bump only adds sub-vertex fine detail.
|
||||||
float posScale = max(length(dp1) + length(dp2), 1e-6);
|
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 bumpVec = N - bumpStr * (dhx * T + dhy * B);
|
||||||
vec3 bumpN = length(bumpVec) > 1e-6 ? normalize(bumpVec) : N;
|
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.mappingBlend.value = settings.mappingBlend ?? 0.0;
|
||||||
u.seamBandWidth.value = settings.seamBandWidth ?? 0.35;
|
u.seamBandWidth.value = settings.seamBandWidth ?? 0.35;
|
||||||
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
||||||
|
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||||
@@ -318,6 +342,7 @@ function buildUniforms(tex, settings) {
|
|||||||
mappingBlend: { value: settings.mappingBlend ?? 0.0 },
|
mappingBlend: { value: settings.mappingBlend ?? 0.0 },
|
||||||
seamBandWidth: { value: settings.seamBandWidth ?? 0.35 },
|
seamBandWidth: { value: settings.seamBandWidth ?? 0.35 },
|
||||||
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
||||||
|
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* Get the grid object so callers can adjust position.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user