feat: add symmetric displacement feature with UI integration and update displacement logic

This commit is contained in:
CNCKitchen
2026-03-19 14:52:21 +01:00
parent e555a479fb
commit 14987b8587
5 changed files with 54 additions and 29 deletions
+19 -20
View File
@@ -1,5 +1,5 @@
import * as THREE from 'three';
import { computeUV, getDominantCubicAxis } from './mapping.js';
import { computeUV, getDominantCubicAxis, getCubicBlendWeights } from './mapping.js';
/**
* Apply displacement to every vertex of a non-indexed BufferGeometry.
@@ -7,7 +7,8 @@ import { computeUV, getDominantCubicAxis } from './mapping.js';
* For each vertex:
* 1. Compute UV with the same math used in the GLSL preview shader (mapping.js).
* 2. Bilinear-sample the greyscale ImageData at that UV.
* 3. Move the vertex along its normal by: grey * amplitude
* 3. Move the vertex along its normal by: (grey 0.5) × 2 × amplitude
* so 50% grey = no displacement, white = outward, black = inward.
*
* @param {THREE.BufferGeometry} geometry non-indexed (from subdivide())
* @param {ImageData} imageData raw pixel data from Canvas2D
@@ -113,24 +114,21 @@ 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.
// For cubic mapping: distribute this face's area across projection zones
// proportionally to its blend weights. When blend=0, getCubicBlendWeights
// returns a one-hot vector (same as the old argmax), preserving sharp seams.
// When blend>0, faces near a zone boundary contribute partial area to
// adjacent zones, creating a smooth multi-vertex-wide gradient that matches
// the preview shader. The old single-zone approach only blended at the
// one-vertex-wide boundary, leaving an abrupt seam in the export.
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;
}
const cubicBlend = settings.mappingBlend ?? 0;
const unitFaceNrm = { x: faceNrm.x / faceArea, y: faceNrm.y / faceArea, z: faceNrm.z / faceArea };
const w = getCubicBlendWeights(unitFaceNrm, cubicBlend);
czX = w.x * faceArea;
czY = w.y * faceArea;
czZ = w.z * faceArea;
}
for (let v = 0; v < 3; v++) {
@@ -146,7 +144,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} else {
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
}
if (czX > 0 || czY > 0 || czZ > 0) {
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
const za = zoneAreaMap.get(k);
if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; }
else { zoneAreaMap.set(k, [czX, czY, czZ]); }
@@ -252,7 +250,8 @@ 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 disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * grey * settings.amplitude;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) * 2.0 : grey;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + sn[0] * disp;
const newY = tmpPos.y + sn[1] * disp;
+9 -1
View File
@@ -92,6 +92,10 @@ 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.',
// Symmetric displacement
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 Amplitude exceeds 10% of the smallest model dimension \u2014 geometry overlaps may occur in the exported STL.',
@@ -221,8 +225,12 @@ 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.',
// Symmetric displacement
'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.',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 Amplitude überschreitet 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.',
// Export section
'sections.export': 'Export \u24d8',
+9 -3
View File
@@ -50,7 +50,8 @@ const settings = {
lockScale: true,
bottomAngleLimit: 5,
topAngleLimit: 0,
mappingBlend: 0.2,
mappingBlend: 1,
symmetricDisplacement: false,
};
// ── DOM refs ──────────────────────────────────────────────────────────────────
@@ -99,6 +100,7 @@ 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');
const symmetricDispToggle = document.getElementById('symmetric-displacement');
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
const exclBrushBtn = document.getElementById('excl-brush-btn');
@@ -310,6 +312,10 @@ function wireEvents() {
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); });
symmetricDispToggle.addEventListener('change', () => {
settings.symmetricDisplacement = symmetricDispToggle.checked;
updatePreview();
});
// ── Export ──
exportBtn.addEventListener('click', () => {
@@ -723,8 +729,8 @@ async function handleSTL(file) {
slider.value = value;
valEl.value = value;
};
settings.scaleU = 1; scaleUSlider.value = scaleToPos(1); scaleUVal.value = 1;
settings.scaleV = 1; scaleVSlider.value = scaleToPos(1); scaleVVal.value = 1;
settings.scaleU = 0.5; scaleUSlider.value = scaleToPos(0.5); scaleUVal.value = 0.5;
settings.scaleV = 0.5; scaleVSlider.value = scaleToPos(0.5); scaleVVal.value = 0.5;
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0);
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
triLimitWarning.classList.add('hidden');
+7 -3
View File
@@ -55,6 +55,7 @@ const fragmentShader = /* glsl */`
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 int symmetricDisplacement; // 1 = remap [0,1]→[-1,1] so 50% grey = no disp
varying vec3 vModelPos;
varying vec3 vModelNormal;
@@ -193,6 +194,7 @@ const fragmentShader = /* glsl */`
// 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
// ── Surface angle masking (FDM: suppress texture on near-horizontal faces) ────
// Use a 15° smoothstep fade above the threshold so the bump tapers gradually
@@ -204,7 +206,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.5, h, maskBlend); // blend toward neutral grey (zero-gradient → no bump)
h = mix(0.0, h, maskBlend); // blend toward neutral (zero-gradient → no bump)
// ── Bump mapping via screen-space height derivatives ──────────────────
float dhx = dFdx(h);
@@ -287,7 +289,8 @@ 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;
u.mappingBlend.value = settings.mappingBlend ?? 0.0;
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
}
// ── Internal ──────────────────────────────────────────────────────────────────
@@ -310,7 +313,8 @@ 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 },
mappingBlend: { value: settings.mappingBlend ?? 0.0 },
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
};
}