diff --git a/index.html b/index.html index f3d74ce..123ebea 100644 --- a/index.html +++ b/index.html @@ -193,6 +193,19 @@

Displacement

Transform

+
+ +
+ +
diff --git a/js/displacement.js b/js/displacement.js index 5406f01..6c746e9 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { computeUV, getDominantCubicAxis, getCubicBlendWeights } from './mapping.js'; +import { computeUV, getDominantCubicAxis, getCubicBlendWeights, getReferenceExtent } from './mapping.js'; /** * Apply displacement to every vertex of a non-indexed BufferGeometry. @@ -320,7 +320,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid]; const total = zaX + zaY + zaZ; if (total > 0) { - const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6); + const md = getReferenceExtent(settings, bounds); const rotRad = (settings.rotation ?? 0) * Math.PI / 180; let grey = 0; if (zaX > 0) { // X-dominant zone → YZ projection diff --git a/js/i18n/de.js b/js/i18n/de.js index 805869c..528c9a8 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -24,6 +24,10 @@ export default { "projection.planarXZ": "Planar XZ", "projection.planarYZ": "Planar YZ", "sections.transform": "Transformation", + "labels.fixedWorldTexture": "Feste Texturskalierung (mm)", + "tooltips.fixedWorldTexture": "Wenn aktiv, wird der UV-Abstand mit einer festen Länge in Millimetern statt der größten Bemaßung des Teils normiert — dieselben Einstellungen wirken auf verschiedenen Meshes ähnlich groß. Zylinder- und Kugelmodus nutzen weiterhin mesh-basierte Radien.", + "labels.referenceExtentMm": "Referenzmaß (mm)", + "tooltips.referenceExtentMm": "Millimetre zur Normierung der UV-Koordinaten pro Achse (vor Skalierung U/V). Gleicher Wert auf mehreren Teilen für einheitliche Mustergröße.", "labels.scaleU": "Skalierung U", "labels.scaleV": "Skalierung V", "labels.offsetU": "Versatz U", diff --git a/js/i18n/en.js b/js/i18n/en.js index 530dbab..9f71837 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -24,6 +24,10 @@ export default { "projection.planarXZ": "Planar XZ", "projection.planarYZ": "Planar YZ", "sections.transform": "Transform", + "labels.fixedWorldTexture": "Fixed texture scale (world mm)", + "tooltips.fixedWorldTexture": "When enabled, UV spacing uses a fixed length in millimetres instead of the part’s largest dimension, so the same settings look similar in physical size on different meshes. Cylindrical and spherical modes still use mesh-based radii.", + "labels.referenceExtentMm": "Reference extent (mm)", + "tooltips.referenceExtentMm": "Millimetres used to normalize UV coordinates along each axis (before Scale U/V). Use the same value across parts for consistent pattern size.", "labels.scaleU": "Scale U", "labels.scaleV": "Scale V", "labels.offsetU": "Offset U", diff --git a/js/main.js b/js/main.js index e4967dd..e1da192 100644 --- a/js/main.js +++ b/js/main.js @@ -76,6 +76,10 @@ const settings = { boundaryFalloff: 0, symmetricDisplacement: false, useDisplacement: false, + /** When true, UV normalization uses referenceExtentMm instead of each mesh's largest bbox edge. */ + fixedWorldTextureScale: false, + /** Millimetres: one normalized UV span along an axis (before Scale U/V). */ + referenceExtentMm: 200, }; // ── Canvas filter support (Safari / iOS WebView don't support ctx.filter) ──── @@ -234,6 +238,15 @@ const boundaryFalloffSlider = document.getElementById('boundary-falloff'); const boundaryFalloffVal = document.getElementById('boundary-falloff-val'); const symmetricDispToggle = document.getElementById('symmetric-displacement'); const dispPreviewToggle = document.getElementById('displacement-preview'); +const fixedWorldTextureToggle = document.getElementById('fixed-world-texture'); +const referenceExtentRow = document.getElementById('reference-extent-row'); +const referenceExtentMmVal = document.getElementById('reference-extent-mm'); + +function refreshReferenceExtentUi() { + if (referenceExtentRow) { + referenceExtentRow.style.display = settings.fixedWorldTextureScale ? '' : 'none'; + } +} // ── Exclusion panel DOM refs ────────────────────────────────────────────────── const exclBrushBtn = document.getElementById('excl-brush-btn'); @@ -374,6 +387,7 @@ document.getElementById('theme-toggle').addEventListener('click', () => { }); wireEvents(); +refreshReferenceExtentUi(); // Sync scale number inputs with the slider's initial position scaleUVal.value = posToScale(parseFloat(scaleUSlider.value)); scaleVVal.value = posToScale(parseFloat(scaleVSlider.value)); @@ -580,6 +594,31 @@ function wireEvents() { } }); + if (fixedWorldTextureToggle) { + fixedWorldTextureToggle.addEventListener('change', () => { + settings.fixedWorldTextureScale = fixedWorldTextureToggle.checked; + refreshReferenceExtentUi(); + clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); + }); + } + + function applyReferenceExtentFromInput() { + if (!referenceExtentMmVal) return; + let v = parseFloat(referenceExtentMmVal.value); + if (!Number.isFinite(v)) v = settings.referenceExtentMm; + v = Math.max(0.1, Math.min(10000, v)); + settings.referenceExtentMm = v; + referenceExtentMmVal.value = v; + clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80); + } + if (referenceExtentMmVal) { + referenceExtentMmVal.addEventListener('change', applyReferenceExtentFromInput); + addFineWheelSupport(referenceExtentMmVal, (v) => { + referenceExtentMmVal.value = v; + applyReferenceExtentFromInput(); + }); + } + 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); }); diff --git a/js/mapping.js b/js/mapping.js index 356ea57..c715b33 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -13,6 +13,21 @@ export const MODE_TRIPLANAR = 5; export const MODE_CUBIC = 6; const TWO_PI = Math.PI * 2; + +/** + * Millimetres used to normalize planar / triplanar / cubic UV coordinates. + * Default (fixed off): each mesh's largest bounding-box edge — pattern scales with part size. + * Fixed world scale: user `referenceExtentMm` — same profile yields similar physical pattern size on any mesh. + */ +export function getReferenceExtent(settings, bounds) { + const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z); + const md = Math.max(maxDim, 1e-6); + if (settings.fixedWorldTextureScale) { + const ref = Number(settings.referenceExtentMm); + if (Number.isFinite(ref) && ref > 0) return ref; + } + return md; +} const CUBIC_AXIS_EPSILON = 1e-4; export function getDominantCubicAxis(normal) { @@ -114,8 +129,7 @@ export function computeUV(pos, normal, mode, settings, bounds) { const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const cosR = Math.cos(rotRad); const sinR = Math.sin(rotRad); - const maxDim = Math.max(size.x, size.y, size.z); - const md = Math.max(maxDim, 1e-6); + const md = getReferenceExtent(settings, bounds); let u = 0, v = 0; diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 7cd3fe8..f15520f 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -39,6 +39,8 @@ const sharedGLSL = /* glsl */` uniform int symmetricDisplacement; uniform int useDisplacement; uniform vec2 textureAspect; + uniform int useFixedReference; + uniform float referenceExtentMm; const float PI = 3.14159265358979; const float TWO_PI = 6.28318530717959; @@ -99,6 +101,9 @@ const sharedGLSL = /* glsl */` vec3 rel = pos - boundsCenter; float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z)); float md = max(maxDim, 1e-4); + if (useFixedReference > 0) { + md = max(referenceExtentMm, 1e-4); + } if (mappingMode == 0) { return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); @@ -435,6 +440,8 @@ export function updateMaterial(material, displacementTexture, settings) { u.useDisplacement.value = settings.useDisplacement ? 1 : 0; u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1); u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0.0; + u.useFixedReference.value = settings.fixedWorldTextureScale ? 1 : 0; + u.referenceExtentMm.value = Math.max(Number(settings.referenceExtentMm) || 200, 1e-4); } // ── Internal ────────────────────────────────────────────────────────────────── @@ -467,6 +474,8 @@ function buildUniforms(tex, settings) { boundaryEdgeCount: { value: 0 }, boundaryEdgeTexWidth: { value: 1.0 }, boundaryFalloffDist: { value: settings.boundaryFalloff ?? 0.0 }, + useFixedReference: { value: settings.fixedWorldTextureScale ? 1 : 0 }, + referenceExtentMm: { value: Math.max(Number(settings.referenceExtentMm) || 200, 1e-4) }, }; }