mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
Enhance UI for scale and offset controls; add surface masking options and improve displacement logic
This commit is contained in:
+25
-9
@@ -96,8 +96,8 @@
|
|||||||
|
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="scale-u">Scale U</label>
|
<label for="scale-u">Scale U</label>
|
||||||
<input type="range" id="scale-u" min="0.1" max="10" step="0.05" value="1" />
|
<input type="range" id="scale-u" min="0" max="1000" step="1" value="500" />
|
||||||
<span class="val" id="scale-u-val">1.00</span>
|
<input type="number" class="val" id="scale-u-val" value="1" min="0.1" max="10" step="0.1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="lock-row">
|
<div class="lock-row">
|
||||||
<div class="lock-line"></div>
|
<div class="lock-line"></div>
|
||||||
@@ -111,18 +111,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="scale-v">Scale V</label>
|
<label for="scale-v">Scale V</label>
|
||||||
<input type="range" id="scale-v" min="0.1" max="10" step="0.05" value="1" />
|
<input type="range" id="scale-v" min="0" max="1000" step="1" value="500" />
|
||||||
<span class="val" id="scale-v-val">1.00</span>
|
<input type="number" class="val" id="scale-v-val" value="1" min="0.1" max="10" step="0.1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="offset-u">Offset U</label>
|
<label for="offset-u">Offset U</label>
|
||||||
<input type="range" id="offset-u" min="-1" max="1" step="0.01" value="0" />
|
<input type="range" id="offset-u" min="-1" max="1" step="0.01" value="0" />
|
||||||
<span class="val" id="offset-u-val">0.00</span>
|
<input type="number" class="val" id="offset-u-val" value="0" min="-1" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="offset-v">Offset V</label>
|
<label for="offset-v">Offset V</label>
|
||||||
<input type="range" id="offset-v" min="-1" max="1" step="0.01" value="0" />
|
<input type="range" id="offset-v" min="-1" max="1" step="0.01" value="0" />
|
||||||
<span class="val" id="offset-v-val">0.00</span>
|
<input type="number" class="val" id="offset-v-val" value="0" min="-1" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -132,21 +132,37 @@
|
|||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="amplitude">Amplitude</label>
|
<label for="amplitude">Amplitude</label>
|
||||||
<input type="range" id="amplitude" min="-1" max="1" step="0.01" value="0.5" />
|
<input type="range" id="amplitude" min="-1" max="1" step="0.01" value="0.5" />
|
||||||
<span class="val" id="amplitude-val">0.5 mm</span>
|
<input type="number" class="val" id="amplitude-val" value="0.5" min="-1" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Surface Mask -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Surface Mask</h2>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="bottom-angle-limit" title="Suppress texture on downward-facing surfaces within this angle of horizontal">Bottom faces</label>
|
||||||
|
<input type="range" id="bottom-angle-limit" min="0" max="90" step="1" value="5" />
|
||||||
|
<input type="number" class="val" id="bottom-angle-limit-val" value="5" min="0" max="90" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="top-angle-limit" title="Suppress texture on upward-facing surfaces within this angle of horizontal">Top faces</label>
|
||||||
|
<input type="range" id="top-angle-limit" min="0" max="90" step="1" value="0" />
|
||||||
|
<input type="number" class="val" id="top-angle-limit-val" value="0" min="0" max="90" step="1" />
|
||||||
|
</div>
|
||||||
|
<p class="hint">0° = no masking. Surfaces within this angle of horizontal will not be textured.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<h2>Export</h2>
|
<h2>Export</h2>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="refine-length" title="Edges longer than this value will be split during export">Max Edge Length</label>
|
<label for="refine-length" title="Edges longer than this value will be split during export">Max Edge Length</label>
|
||||||
<input type="range" id="refine-length" min="0.1" max="5" step="0.1" value="1" />
|
<input type="range" id="refine-length" min="0.1" max="5" step="0.1" value="1" />
|
||||||
<span class="val" id="refine-length-val">1.0 mm</span>
|
<input type="number" class="val" id="refine-length-val" value="1" min="0.1" max="5" step="0.1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="max-triangles" title="Mesh is fully subdivided first, then decimated down to this count">Output Triangles</label>
|
<label for="max-triangles" title="Mesh is fully subdivided first, then decimated down to this count">Output Triangles</label>
|
||||||
<input type="range" id="max-triangles" min="100000" max="5000000" step="100000" value="1000000" />
|
<input type="range" id="max-triangles" min="10000" max="5000000" step="10000" value="1000000" />
|
||||||
<span class="val" id="max-triangles-val">1.0 M</span>
|
<span class="val" id="max-triangles-val">1.0 M</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="tri-limit-warning" class="tri-limit-warning hidden">
|
<div id="tri-limit-warning" class="tri-limit-warning hidden">
|
||||||
|
|||||||
+42
-5
@@ -56,8 +56,12 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
// so printed edges remain sharp.
|
// so printed edges remain sharp.
|
||||||
|
|
||||||
// ── Pass 1: accumulate area-weighted face normals per unique position ─────
|
// ── 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();
|
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) {
|
for (let t = 0; t < count; t += 3) {
|
||||||
vA.fromBufferAttribute(posAttr, t);
|
vA.fromBufferAttribute(posAttr, t);
|
||||||
@@ -67,6 +71,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
edge2.subVectors(vC, vA);
|
edge2.subVectors(vC, vA);
|
||||||
faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting
|
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++) {
|
for (let v = 0; v < 3; v++) {
|
||||||
tmpPos.fromBufferAttribute(posAttr, t + v);
|
tmpPos.fromBufferAttribute(posAttr, t + v);
|
||||||
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
@@ -78,6 +90,13 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
} else {
|
} else {
|
||||||
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
|
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 k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
const sn = smoothNrmMap.get(k);
|
const sn = smoothNrmMap.get(k);
|
||||||
const grey = dispCache.get(k);
|
const grey = dispCache.get(k);
|
||||||
const disp = grey * settings.amplitude;
|
|
||||||
|
|
||||||
newPos[i*3] = tmpPos.x + sn[0] * disp;
|
// Smooth blend: displacement scaled by the unmasked fraction of surrounding
|
||||||
newPos[i*3+1] = tmpPos.y + sn[1] * disp;
|
// face area. Boundary vertices (shared by masked + unmasked faces) get a
|
||||||
newPos[i*3+2] = tmpPos.z + sn[2] * disp;
|
// 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)
|
// Keep per-face normal for shading (recomputed below anyway)
|
||||||
newNrm[i*3] = tmpNrm.x;
|
newNrm[i*3] = tmpNrm.x;
|
||||||
|
|||||||
+66
-36
@@ -25,6 +25,8 @@ const settings = {
|
|||||||
refineLength: 1.0,
|
refineLength: 1.0,
|
||||||
maxTriangles: 1_000_000,
|
maxTriangles: 1_000_000,
|
||||||
lockScale: true,
|
lockScale: true,
|
||||||
|
bottomAngleLimit: 5,
|
||||||
|
topAngleLimit: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
@@ -62,11 +64,27 @@ const amplitudeVal = document.getElementById('amplitude-val');
|
|||||||
const refineLenVal = document.getElementById('refine-length-val');
|
const refineLenVal = document.getElementById('refine-length-val');
|
||||||
const maxTriVal = document.getElementById('max-triangles-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 ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
initViewer(canvas);
|
initViewer(canvas);
|
||||||
buildPresetGrid();
|
buildPresetGrid();
|
||||||
wireEvents();
|
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 ───────────────────────────────────────────────────────────────
|
// ── Preset grid ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,52 +162,49 @@ function wireEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Scale U — when lock is on, mirror to V
|
// Scale U — when lock is on, mirror to V
|
||||||
scaleUSlider.addEventListener('input', () => {
|
const applyScaleU = (v) => {
|
||||||
const v = parseFloat(scaleUSlider.value);
|
v = Math.max(0.1, Math.min(10, v));
|
||||||
settings.scaleU = v;
|
settings.scaleU = v;
|
||||||
scaleUVal.textContent = v.toFixed(2);
|
scaleUSlider.value = scaleToPos(v);
|
||||||
if (settings.lockScale) {
|
scaleUVal.value = v;
|
||||||
settings.scaleV = v;
|
if (settings.lockScale) { settings.scaleV = v; scaleVSlider.value = scaleToPos(v); scaleVVal.value = v; }
|
||||||
scaleVSlider.value = v;
|
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
|
||||||
scaleVVal.textContent = v.toFixed(2);
|
};
|
||||||
}
|
scaleUSlider.addEventListener('input', () => applyScaleU(posToScale(parseFloat(scaleUSlider.value))));
|
||||||
clearTimeout(previewDebounce);
|
scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value)));
|
||||||
previewDebounce = setTimeout(updatePreview, 80);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scale V — when lock is on, mirror to U
|
// Scale V — when lock is on, mirror to U
|
||||||
scaleVSlider.addEventListener('input', () => {
|
const applyScaleV = (v) => {
|
||||||
const v = parseFloat(scaleVSlider.value);
|
v = Math.max(0.1, Math.min(10, v));
|
||||||
settings.scaleV = v;
|
settings.scaleV = v;
|
||||||
scaleVVal.textContent = v.toFixed(2);
|
scaleVSlider.value = scaleToPos(v);
|
||||||
if (settings.lockScale) {
|
scaleVVal.value = v;
|
||||||
settings.scaleU = v;
|
if (settings.lockScale) { settings.scaleU = v; scaleUSlider.value = scaleToPos(v); scaleUVal.value = v; }
|
||||||
scaleUSlider.value = v;
|
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
|
||||||
scaleUVal.textContent = v.toFixed(2);
|
};
|
||||||
}
|
scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value))));
|
||||||
clearTimeout(previewDebounce);
|
scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value)));
|
||||||
previewDebounce = setTimeout(updatePreview, 80);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lock toggle
|
// Lock toggle
|
||||||
lockScaleBtn.addEventListener('click', () => {
|
lockScaleBtn.addEventListener('click', () => {
|
||||||
settings.lockScale = !settings.lockScale;
|
settings.lockScale = !settings.lockScale;
|
||||||
lockScaleBtn.classList.toggle('active', settings.lockScale);
|
lockScaleBtn.classList.toggle('active', settings.lockScale);
|
||||||
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
|
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
|
||||||
// When locking, snap V to current U
|
|
||||||
if (settings.lockScale) {
|
if (settings.lockScale) {
|
||||||
settings.scaleV = settings.scaleU;
|
settings.scaleV = settings.scaleU;
|
||||||
scaleVSlider.value = settings.scaleU;
|
scaleVSlider.value = scaleToPos(settings.scaleU);
|
||||||
scaleVVal.textContent = settings.scaleU.toFixed(2);
|
scaleVVal.value = settings.scaleU;
|
||||||
updatePreview();
|
updatePreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
|
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
|
||||||
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = 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(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); });
|
||||||
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return `${v.toFixed(1)} mm`; }, false);
|
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(1); }, false);
|
||||||
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, 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 ──
|
// ── Export ──
|
||||||
exportBtn.addEventListener('click', handleExport);
|
exportBtn.addEventListener('click', handleExport);
|
||||||
@@ -202,15 +217,30 @@ function wireEvents() {
|
|||||||
|
|
||||||
let previewDebounce = null;
|
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', () => {
|
slider.addEventListener('input', () => {
|
||||||
const v = parseFloat(slider.value);
|
const v = parseFloat(slider.value);
|
||||||
valEl.textContent = onChangeFn(v);
|
const display = onChangeFn(v);
|
||||||
|
if (isSpan) valInput.textContent = display; else valInput.value = display;
|
||||||
if (livePreview) {
|
if (livePreview) {
|
||||||
clearTimeout(previewDebounce);
|
clearTimeout(previewDebounce);
|
||||||
previewDebounce = setTimeout(updatePreview, 80);
|
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) {
|
function formatM(n) {
|
||||||
@@ -248,14 +278,14 @@ async function handleSTL(file) {
|
|||||||
dropHint.classList.add('hidden');
|
dropHint.classList.add('hidden');
|
||||||
|
|
||||||
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
|
// 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;
|
slider.value = value;
|
||||||
valEl.textContent = fmt(value);
|
valEl.value = value;
|
||||||
};
|
};
|
||||||
settings.scaleU = 1; resetVal(scaleUSlider, scaleUVal, 1, v => v.toFixed(2));
|
settings.scaleU = 1; scaleUSlider.value = scaleToPos(1); scaleUVal.value = 1;
|
||||||
settings.scaleV = 1; resetVal(scaleVSlider, scaleVVal, 1, v => v.toFixed(2));
|
settings.scaleV = 1; scaleVSlider.value = scaleToPos(1); scaleVVal.value = 1;
|
||||||
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0, v => v.toFixed(2));
|
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0);
|
||||||
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0, v => v.toFixed(2));
|
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
|
||||||
triLimitWarning.classList.add('hidden');
|
triLimitWarning.classList.add('hidden');
|
||||||
|
|
||||||
// Default edge length = 1/100 of the largest bounding box dimension
|
// 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)));
|
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
||||||
settings.refineLength = defaultEdge;
|
settings.refineLength = defaultEdge;
|
||||||
refineLenSlider.value = defaultEdge;
|
refineLenSlider.value = defaultEdge;
|
||||||
refineLenVal.textContent = `${defaultEdge.toFixed(2)} mm`;
|
refineLenVal.value = defaultEdge;
|
||||||
|
|
||||||
const triCount = getTriangleCount(geometry);
|
const triCount = getTriangleCount(geometry);
|
||||||
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
||||||
|
|||||||
+7
-7
@@ -107,8 +107,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
triplanar: false,
|
triplanar: false,
|
||||||
u: fract(uRaw * scaleU + offsetU),
|
u: fract(uRaw / scaleU + offsetU),
|
||||||
v: fract(vRaw * scaleV + offsetV),
|
v: fract(vRaw / scaleV + offsetV),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +149,9 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
return {
|
return {
|
||||||
triplanar: true,
|
triplanar: true,
|
||||||
samples: [
|
samples: [
|
||||||
{ u: fract(uvXY.u * scaleU + offsetU), v: fract(uvXY.v * scaleV + offsetV), w: uvXY.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(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(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 {
|
return {
|
||||||
triplanar: false,
|
triplanar: false,
|
||||||
u: fract(u * scaleU + offsetU),
|
u: fract(u / scaleU + offsetU),
|
||||||
v: fract(v * scaleV + offsetV),
|
v: fract(v / scaleV + offsetV),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -46,6 +46,8 @@ const fragmentShader = /* glsl */`
|
|||||||
uniform vec3 boundsMin;
|
uniform vec3 boundsMin;
|
||||||
uniform vec3 boundsSize;
|
uniform vec3 boundsSize;
|
||||||
uniform vec3 boundsCenter;
|
uniform vec3 boundsCenter;
|
||||||
|
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
|
||||||
|
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
|
||||||
|
|
||||||
varying vec3 vModelPos;
|
varying vec3 vModelPos;
|
||||||
varying vec3 vModelNormal;
|
varying vec3 vModelNormal;
|
||||||
@@ -57,7 +59,7 @@ const fragmentShader = /* glsl */`
|
|||||||
|
|
||||||
// Sample after applying scale + tiling
|
// Sample after applying scale + tiling
|
||||||
float sampleMap(vec2 rawUV) {
|
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.
|
// Height at this fragment for all projection modes.
|
||||||
@@ -138,7 +140,17 @@ const fragmentShader = /* glsl */`
|
|||||||
void main() {
|
void main() {
|
||||||
vec3 N = normalize(vNormal);
|
vec3 N = normalize(vNormal);
|
||||||
float h = getHeight();
|
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 ──────────────────
|
// ── Bump mapping via screen-space height derivatives ──────────────────
|
||||||
// dFdx/dFdy give the height change per screen pixel → height gradient
|
// dFdx/dFdy give the height change per screen pixel → height gradient
|
||||||
float dhx = dFdx(h);
|
float dhx = dFdx(h);
|
||||||
@@ -222,6 +234,8 @@ export function updateMaterial(material, displacementTexture, settings) {
|
|||||||
u.boundsSize.value.copy(settings.bounds.size);
|
u.boundsSize.value.copy(settings.bounds.size);
|
||||||
u.boundsCenter.value.copy(settings.bounds.center);
|
u.boundsCenter.value.copy(settings.bounds.center);
|
||||||
}
|
}
|
||||||
|
u.bottomAngleLimit.value = settings.bottomAngleLimit ?? 5.0;
|
||||||
|
u.topAngleLimit.value = settings.topAngleLimit ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||||
@@ -241,6 +255,8 @@ function buildUniforms(tex, settings) {
|
|||||||
boundsMin: { value: b.min.clone() },
|
boundsMin: { value: b.min.clone() },
|
||||||
boundsSize: { value: b.size.clone() },
|
boundsSize: { value: b.size.clone() },
|
||||||
boundsCenter: { value: b.center.clone() },
|
boundsCenter: { value: b.center.clone() },
|
||||||
|
bottomAngleLimit: { value: settings.bottomAngleLimit ?? 5.0 },
|
||||||
|
topAngleLimit: { value: settings.topAngleLimit ?? 0.0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
--danger: #ff5f5f;
|
--danger: #ff5f5f;
|
||||||
--success: #4ade80;
|
--success: #4ade80;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--sidebar-w: 310px;
|
--sidebar-w: 380px;
|
||||||
--header-h: 48px;
|
--header-h: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,12 +323,26 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.val {
|
.val {
|
||||||
flex: 0 0 56px;
|
flex: 0 0 52px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
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 text ───────────────────────────────────────────────────────── */
|
||||||
.hint {
|
.hint {
|
||||||
|
|||||||
Reference in New Issue
Block a user