From 14987b85872afe9bba23e70505d877f198ff84fa Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Thu, 19 Mar 2026 14:52:21 +0100 Subject: [PATCH] feat: add symmetric displacement feature with UI integration and update displacement logic --- index.html | 12 ++++++++++-- js/displacement.js | 39 +++++++++++++++++++-------------------- js/i18n.js | 10 +++++++++- js/main.js | 12 +++++++++--- js/previewMaterial.js | 10 +++++++--- 5 files changed, 54 insertions(+), 29 deletions(-) diff --git a/index.html b/index.html index 867b865..18795da 100644 --- a/index.html +++ b/index.html @@ -132,8 +132,8 @@
- - + +
@@ -148,6 +148,14 @@ +
+ +
diff --git a/js/displacement.js b/js/displacement.js index 4a594d4..94d217e 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -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; diff --git a/js/i18n.js b/js/i18n.js index 8a9f5f3..ce55da8 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -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', diff --git a/js/main.js b/js/main.js index 4193e74..ce67aa0 100644 --- a/js/main.js +++ b/js/main.js @@ -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'); diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 933fac6..66a84af 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -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 }, }; }