diff --git a/index.html b/index.html
index a1ca840..686e616 100644
--- a/index.html
+++ b/index.html
@@ -96,8 +96,8 @@
Export
- 1.0 mm
+
-
+
1.0 M
diff --git a/js/displacement.js b/js/displacement.js
index a9e0cae..fc91993 100644
--- a/js/displacement.js
+++ b/js/displacement.js
@@ -56,8 +56,12 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// so printed edges remain sharp.
// ── Pass 1: accumulate area-weighted face normals per unique position ─────
- // Map: posKey → { nx, ny, nz } (unnormalised sum)
+ // Map: posKey → [nx, ny, nz] (unnormalised sum)
const smoothNrmMap = 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.
+ const maskedFracMap = new Map();
for (let t = 0; t < count; t += 3) {
vA.fromBufferAttribute(posAttr, t);
@@ -67,6 +71,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
edge2.subVectors(vC, vA);
faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting
+ // Determine if this face is masked (used to build the per-vertex blend weight)
+ const faceArea = faceNrm.length(); // ∝ 2× triangle area
+ const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; // unit-normal Z component
+ const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
+ const faceMasked = faceNzNorm < 0
+ ? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
+ : (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
+
for (let v = 0; v < 3; v++) {
tmpPos.fromBufferAttribute(posAttr, t + v);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
@@ -78,6 +90,13 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} else {
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
}
+ const mf = maskedFracMap.get(k);
+ if (mf) {
+ if (faceMasked) mf[0] += faceArea;
+ mf[1] += faceArea;
+ } else {
+ maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]);
+ }
}
}
@@ -124,11 +143,29 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
const sn = smoothNrmMap.get(k);
const grey = dispCache.get(k);
- const disp = grey * settings.amplitude;
- newPos[i*3] = tmpPos.x + sn[0] * disp;
- newPos[i*3+1] = tmpPos.y + sn[1] * disp;
- newPos[i*3+2] = tmpPos.z + sn[2] * disp;
+ // Smooth blend: displacement scaled by the unmasked fraction of surrounding
+ // face area. Boundary vertices (shared by masked + unmasked faces) get a
+ // proportionally reduced displacement instead of a hard on/off cutoff.
+ const mf = maskedFracMap.get(k) || [0, 1];
+ const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
+ const disp = (1 - maskedFrac) * grey * settings.amplitude;
+
+ const newX = tmpPos.x + sn[0] * disp;
+ const newY = tmpPos.y + sn[1] * disp;
+ let newZ = tmpPos.z + sn[2] * disp;
+
+ // Prevent boundary vertices from poking through the masked surface in Z.
+ // Only triggers for vertices that are partly masked (maskedFrac > 0) and
+ // whose displacement would push them toward the masked surface direction.
+ if (maskedFrac > 0) {
+ if (settings.bottomAngleLimit > 0 && newZ < tmpPos.z) newZ = tmpPos.z;
+ if (settings.topAngleLimit > 0 && newZ > tmpPos.z) newZ = tmpPos.z;
+ }
+
+ newPos[i*3] = newX;
+ newPos[i*3+1] = newY;
+ newPos[i*3+2] = newZ;
// Keep per-face normal for shading (recomputed below anyway)
newNrm[i*3] = tmpNrm.x;
diff --git a/js/main.js b/js/main.js
index 0301982..cbdfe4b 100644
--- a/js/main.js
+++ b/js/main.js
@@ -25,6 +25,8 @@ const settings = {
refineLength: 1.0,
maxTriangles: 1_000_000,
lockScale: true,
+ bottomAngleLimit: 5,
+ topAngleLimit: 0,
};
// ── DOM refs ──────────────────────────────────────────────────────────────────
@@ -62,11 +64,27 @@ const amplitudeVal = document.getElementById('amplitude-val');
const refineLenVal = document.getElementById('refine-length-val');
const maxTriVal = document.getElementById('max-triangles-val');
+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');
+
+// ── Scale slider log helpers ──────────────────────────────────────────────────
+// Slider stores 0–1000; actual scale spans 0.1–10 on a log axis.
+// Middle position 500 → scale 1.0 (exact midpoint on log scale).
+const _LOG_MIN = Math.log(0.1);
+const _LOG_MAX = Math.log(10);
+const scaleToPos = v => Math.round((Math.log(Math.max(0.1, Math.min(10, v))) - _LOG_MIN) / (_LOG_MAX - _LOG_MIN) * 1000);
+const posToScale = p => parseFloat(Math.exp(_LOG_MIN + (p / 1000) * (_LOG_MAX - _LOG_MIN)).toFixed(1));
+
// ── Init ──────────────────────────────────────────────────────────────────────
initViewer(canvas);
buildPresetGrid();
wireEvents();
+// Sync scale number inputs with the slider's initial position
+scaleUVal.value = posToScale(parseFloat(scaleUSlider.value));
+scaleVVal.value = posToScale(parseFloat(scaleVSlider.value));
// ── Preset grid ───────────────────────────────────────────────────────────────
@@ -144,52 +162,49 @@ function wireEvents() {
});
// Scale U — when lock is on, mirror to V
- scaleUSlider.addEventListener('input', () => {
- const v = parseFloat(scaleUSlider.value);
+ const applyScaleU = (v) => {
+ v = Math.max(0.1, Math.min(10, v));
settings.scaleU = v;
- scaleUVal.textContent = v.toFixed(2);
- if (settings.lockScale) {
- settings.scaleV = v;
- scaleVSlider.value = v;
- scaleVVal.textContent = v.toFixed(2);
- }
- clearTimeout(previewDebounce);
- previewDebounce = setTimeout(updatePreview, 80);
- });
+ scaleUSlider.value = scaleToPos(v);
+ scaleUVal.value = v;
+ if (settings.lockScale) { settings.scaleV = v; scaleVSlider.value = scaleToPos(v); scaleVVal.value = v; }
+ clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
+ };
+ scaleUSlider.addEventListener('input', () => applyScaleU(posToScale(parseFloat(scaleUSlider.value))));
+ scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value)));
// Scale V — when lock is on, mirror to U
- scaleVSlider.addEventListener('input', () => {
- const v = parseFloat(scaleVSlider.value);
+ const applyScaleV = (v) => {
+ v = Math.max(0.1, Math.min(10, v));
settings.scaleV = v;
- scaleVVal.textContent = v.toFixed(2);
- if (settings.lockScale) {
- settings.scaleU = v;
- scaleUSlider.value = v;
- scaleUVal.textContent = v.toFixed(2);
- }
- clearTimeout(previewDebounce);
- previewDebounce = setTimeout(updatePreview, 80);
- });
+ scaleVSlider.value = scaleToPos(v);
+ scaleVVal.value = v;
+ if (settings.lockScale) { settings.scaleU = v; scaleUSlider.value = scaleToPos(v); scaleUVal.value = v; }
+ clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
+ };
+ scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value))));
+ scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value)));
// Lock toggle
lockScaleBtn.addEventListener('click', () => {
settings.lockScale = !settings.lockScale;
lockScaleBtn.classList.toggle('active', settings.lockScale);
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
- // When locking, snap V to current U
if (settings.lockScale) {
settings.scaleV = settings.scaleU;
- scaleVSlider.value = settings.scaleU;
- scaleVVal.textContent = settings.scaleU.toFixed(2);
+ scaleVSlider.value = scaleToPos(settings.scaleU);
+ scaleVVal.value = settings.scaleU;
updatePreview();
}
});
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); });
- linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return `${v.toFixed(2)} mm`; });
- linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return `${v.toFixed(1)} mm`; }, false);
- linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
+ linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); });
+ linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(1); }, 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; });
// ── Export ──
exportBtn.addEventListener('click', handleExport);
@@ -202,15 +217,30 @@ function wireEvents() {
let previewDebounce = null;
-function linkSlider(slider, valEl, onChangeFn, livePreview = true) {
+function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
+ const isSpan = valInput.tagName === 'SPAN';
slider.addEventListener('input', () => {
- const v = parseFloat(slider.value);
- valEl.textContent = onChangeFn(v);
+ const v = parseFloat(slider.value);
+ const display = onChangeFn(v);
+ if (isSpan) valInput.textContent = display; else valInput.value = display;
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
}
});
+ if (!isSpan) {
+ valInput.addEventListener('change', () => {
+ const raw = parseFloat(valInput.value);
+ if (isNaN(raw)) { valInput.value = slider.value; return; }
+ const clamped = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), raw));
+ slider.value = clamped;
+ valInput.value = onChangeFn(clamped);
+ if (livePreview) {
+ clearTimeout(previewDebounce);
+ previewDebounce = setTimeout(updatePreview, 80);
+ }
+ });
+ }
}
function formatM(n) {
@@ -248,14 +278,14 @@ async function handleSTL(file) {
dropHint.classList.add('hidden');
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
- const resetVal = (slider, valEl, value, fmt) => {
+ const resetVal = (slider, valEl, value) => {
slider.value = value;
- valEl.textContent = fmt(value);
+ valEl.value = value;
};
- settings.scaleU = 1; resetVal(scaleUSlider, scaleUVal, 1, v => v.toFixed(2));
- settings.scaleV = 1; resetVal(scaleVSlider, scaleVVal, 1, v => v.toFixed(2));
- settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0, v => v.toFixed(2));
- settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0, v => v.toFixed(2));
+ settings.scaleU = 1; scaleUSlider.value = scaleToPos(1); scaleUVal.value = 1;
+ settings.scaleV = 1; scaleVSlider.value = scaleToPos(1); scaleVVal.value = 1;
+ settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0);
+ settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
triLimitWarning.classList.add('hidden');
// Default edge length = 1/100 of the largest bounding box dimension
@@ -263,7 +293,7 @@ async function handleSTL(file) {
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
- refineLenVal.textContent = `${defaultEdge.toFixed(2)} mm`;
+ refineLenVal.value = defaultEdge;
const triCount = getTriangleCount(geometry);
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
diff --git a/js/mapping.js b/js/mapping.js
index d4d681d..3586323 100644
--- a/js/mapping.js
+++ b/js/mapping.js
@@ -107,8 +107,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
}
return {
triplanar: false,
- u: fract(uRaw * scaleU + offsetU),
- v: fract(vRaw * scaleV + offsetV),
+ u: fract(uRaw / scaleU + offsetU),
+ v: fract(vRaw / scaleV + offsetV),
};
}
@@ -149,9 +149,9 @@ export function computeUV(pos, normal, mode, settings, bounds) {
return {
triplanar: true,
samples: [
- { u: fract(uvXY.u * scaleU + offsetU), v: fract(uvXY.v * scaleV + offsetV), w: uvXY.w },
- { u: fract(uvXZ.u * scaleU + offsetU), v: fract(uvXZ.v * scaleV + offsetV), w: uvXZ.w },
- { u: fract(uvYZ.u * scaleU + offsetU), v: fract(uvYZ.v * scaleV + offsetV), w: uvYZ.w },
+ { u: fract(uvXY.u / scaleU + offsetU), v: fract(uvXY.v / scaleV + offsetV), w: uvXY.w },
+ { u: fract(uvXZ.u / scaleU + offsetU), v: fract(uvXZ.v / scaleV + offsetV), w: uvXZ.w },
+ { u: fract(uvYZ.u / scaleU + offsetU), v: fract(uvYZ.v / scaleV + offsetV), w: uvYZ.w },
],
};
}
@@ -159,8 +159,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
return {
triplanar: false,
- u: fract(u * scaleU + offsetU),
- v: fract(v * scaleV + offsetV),
+ u: fract(u / scaleU + offsetU),
+ v: fract(v / scaleV + offsetV),
};
}
diff --git a/js/previewMaterial.js b/js/previewMaterial.js
index d8b7787..16642c5 100644
--- a/js/previewMaterial.js
+++ b/js/previewMaterial.js
@@ -46,6 +46,8 @@ const fragmentShader = /* glsl */`
uniform vec3 boundsMin;
uniform vec3 boundsSize;
uniform vec3 boundsCenter;
+ uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
+ uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
varying vec3 vModelPos;
varying vec3 vModelNormal;
@@ -57,7 +59,7 @@ const fragmentShader = /* glsl */`
// Sample after applying scale + tiling
float sampleMap(vec2 rawUV) {
- return texture2D(displacementMap, fract(rawUV * scaleUV + offsetUV)).r;
+ return texture2D(displacementMap, fract(rawUV / scaleUV + offsetUV)).r;
}
// Height at this fragment for all projection modes.
@@ -138,7 +140,17 @@ const fragmentShader = /* glsl */`
void main() {
vec3 N = normalize(vNormal);
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.
+ float surfaceAngle = degrees(acos(clamp(abs(vModelNormal.z), 0.0, 1.0)));
+ float maskBlend = 1.0;
+ float FADE = 15.0;
+ if (vModelNormal.z < 0.0 && bottomAngleLimit >= 1.0)
+ 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)
// ── Bump mapping via screen-space height derivatives ──────────────────
// dFdx/dFdy give the height change per screen pixel → height gradient
float dhx = dFdx(h);
@@ -222,6 +234,8 @@ export function updateMaterial(material, displacementTexture, settings) {
u.boundsSize.value.copy(settings.bounds.size);
u.boundsCenter.value.copy(settings.bounds.center);
}
+ u.bottomAngleLimit.value = settings.bottomAngleLimit ?? 5.0;
+ u.topAngleLimit.value = settings.topAngleLimit ?? 0.0;
}
// ── Internal ──────────────────────────────────────────────────────────────────
@@ -238,9 +252,11 @@ function buildUniforms(tex, settings) {
scaleUV: { value: new THREE.Vector2(settings.scaleU ?? 1, settings.scaleV ?? 1) },
amplitude: { value: settings.amplitude ?? 1.0 },
offsetUV: { value: new THREE.Vector2(settings.offsetU ?? 0, settings.offsetV ?? 0) },
- boundsMin: { value: b.min.clone() },
- boundsSize: { value: b.size.clone() },
- boundsCenter: { value: b.center.clone() },
+ boundsMin: { value: b.min.clone() },
+ boundsSize: { value: b.size.clone() },
+ boundsCenter: { value: b.center.clone() },
+ bottomAngleLimit: { value: settings.bottomAngleLimit ?? 5.0 },
+ topAngleLimit: { value: settings.topAngleLimit ?? 0.0 },
};
}
diff --git a/style.css b/style.css
index cbe9cdf..fa0f4d3 100644
--- a/style.css
+++ b/style.css
@@ -13,7 +13,7 @@
--danger: #ff5f5f;
--success: #4ade80;
--radius: 8px;
- --sidebar-w: 310px;
+ --sidebar-w: 380px;
--header-h: 48px;
}
@@ -323,12 +323,26 @@ main {
}
.val {
- flex: 0 0 56px;
+ flex: 0 0 52px;
text-align: right;
font-size: 11px;
- color: var(--text-muted);
font-variant-numeric: tabular-nums;
+ color: var(--text-muted);
}
+input[type="number"].val {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ padding: 2px 6px;
+ cursor: text;
+ box-sizing: border-box;
+ min-width: 0;
+ -moz-appearance: textfield;
+}
+input[type="number"].val::-webkit-inner-spin-button,
+input[type="number"].val::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
+input[type="number"].val:focus { outline: none; border-color: var(--accent); }
/* ── Hint text ───────────────────────────────────────────────────────── */
.hint {