mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: add seam blend feature and amplitude overlap warning with UI updates
This commit is contained in:
+12
-4
@@ -130,6 +130,11 @@
|
||||
<option value="2" data-i18n-opt="projection.planarYZ">Planar YZ</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row slider-row">
|
||||
<label for="seam-blend" data-i18n="labels.seamBlend" data-i18n-title="tooltips.seamBlend" title="Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.">Seam Blend ⓘ</label>
|
||||
<input type="range" id="seam-blend" min="0" max="1" step="0.01" value="0.20" />
|
||||
<input type="number" class="val" id="seam-blend-val" value="0.20" min="0" max="1" step="0.01" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Displacement -->
|
||||
@@ -140,6 +145,9 @@
|
||||
<input type="range" id="amplitude" min="-2" max="2" step="0.01" value="0.5" />
|
||||
<input type="number" class="val" id="amplitude-val" value="0.5" min="-100" max="100" step="0.01" />
|
||||
</div>
|
||||
<div id="amplitude-warning" class="amplitude-warning hidden" data-i18n="warnings.amplitudeOverlap">
|
||||
⚠ Amplitude exceeds 10% of the smallest model dimension — geometry overlaps may occur in the exported STL.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Transform -->
|
||||
@@ -148,8 +156,8 @@
|
||||
|
||||
<div class="form-row slider-row">
|
||||
<label for="scale-u" data-i18n="labels.scaleU">Scale U</label>
|
||||
<input type="range" id="scale-u" min="0" max="1000" step="1" value="500" />
|
||||
<input type="number" class="val" id="scale-u-val" value="1" min="0.05" max="10" step="0.05" />
|
||||
<input type="range" id="scale-u" min="0" max="1000" step="1" value="435" />
|
||||
<input type="number" class="val" id="scale-u-val" value="0.50" min="0.05" max="10" step="0.05" />
|
||||
</div>
|
||||
<div class="lock-row">
|
||||
<div class="lock-line"></div>
|
||||
@@ -164,8 +172,8 @@
|
||||
|
||||
<div class="form-row slider-row">
|
||||
<label for="scale-v" data-i18n="labels.scaleV">Scale V</label>
|
||||
<input type="range" id="scale-v" min="0" max="1000" step="1" value="500" />
|
||||
<input type="number" class="val" id="scale-v-val" value="1" min="0.05" max="10" step="0.05" />
|
||||
<input type="range" id="scale-v" min="0" max="1000" step="1" value="435" />
|
||||
<input type="number" class="val" id="scale-v-val" value="0.50" min="0.05" max="10" step="0.05" />
|
||||
</div>
|
||||
<div class="form-row slider-row">
|
||||
<label for="offset-u" data-i18n="labels.offsetU">Offset U</label>
|
||||
|
||||
+75
-1
@@ -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) };
|
||||
}
|
||||
|
||||
+14
@@ -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.',
|
||||
|
||||
+22
-6
@@ -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;
|
||||
|
||||
|
||||
+57
-29
@@ -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);
|
||||
}
|
||||
|
||||
+61
-51
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user