diff --git a/index.html b/index.html
index f3d74ce..123ebea 100644
--- a/index.html
+++ b/index.html
@@ -193,6 +193,19 @@
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) },
};
}