mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: enhance language selection and boundary falloff features
- Added a language dropdown selector to replace the previous button-based language toggle. - Integrated boundary falloff settings into the preview material, allowing for smoother transitions between masked and unmasked areas. - Updated shaders to utilize boundary falloff attributes for improved visual fidelity in bump-only previews. - Refactored related CSS styles for the new dropdown and adjusted layout for better usability. - Introduced new functions for computing boundary attributes and managing edge data textures.
This commit is contained in:
+14
-19
@@ -15,17 +15,6 @@
|
|||||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light');
|
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light');
|
||||||
})();
|
})();
|
||||||
// Apply saved language before first paint to avoid flash
|
|
||||||
(function() {
|
|
||||||
const l = localStorage.getItem('stlt-lang');
|
|
||||||
if (l === 'de' || l === 'en') {
|
|
||||||
document.documentElement.setAttribute('data-lang', l);
|
|
||||||
document.documentElement.setAttribute('lang', l);
|
|
||||||
} else if (navigator.language && navigator.language.toLowerCase().startsWith('de')) {
|
|
||||||
document.documentElement.setAttribute('data-lang', 'de');
|
|
||||||
document.documentElement.setAttribute('lang', 'de');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
@@ -46,8 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="lang-seg">
|
<div class="lang-seg">
|
||||||
<button class="lang-btn active" data-lang-code="en">EN</button>
|
|
||||||
<button class="lang-btn" data-lang-code="de">DE</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="theme-toggle" class="theme-toggle"
|
<button id="theme-toggle" class="theme-toggle"
|
||||||
data-i18n-title="theme.toggleTitle"
|
data-i18n-title="theme.toggleTitle"
|
||||||
@@ -237,9 +224,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Mask Angles -->
|
<!-- Masking -->
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<h2 data-i18n="sections.maskAngles" data-i18n-title="tooltips.maskAngles" title="0° = no masking. Surfaces within this angle of horizontal will not be textured.">Mask Angles ⓘ</h2>
|
<h2 data-i18n="sections.masking">Masking</h2>
|
||||||
|
|
||||||
|
<!-- Boundary Falloff (applies to both angle and surface masking) -->
|
||||||
|
<div class="form-row slider-row">
|
||||||
|
<label for="boundary-falloff" data-i18n="labels.boundaryFalloff" data-i18n-title="tooltips.boundaryFalloff" title="Gradually reduces displacement to zero near masked boundaries, preventing triangle overlap where textured and non-textured regions meet.">Smooth Mask ⓘ</label>
|
||||||
|
<input type="range" id="boundary-falloff" min="0" max="10" step="0.1" value="0" />
|
||||||
|
<input type="number" class="val" id="boundary-falloff-val" value="0" min="0" max="10" step="0.1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Angle -->
|
||||||
|
<h3 class="panel-subsection-heading" data-i18n="sections.maskAngles" data-i18n-title="tooltips.maskAngles" title="0° = no masking. Surfaces within this angle of horizontal will not be textured.">By Angle ⓘ</h3>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="bottom-angle-limit" data-i18n="labels.bottomFaces" data-i18n-title="tooltips.bottomFaces" title="Suppress texture on downward-facing surfaces within this angle of horizontal">Bottom faces</label>
|
<label for="bottom-angle-limit" data-i18n="labels.bottomFaces" data-i18n-title="tooltips.bottomFaces" 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="range" id="bottom-angle-limit" min="0" max="90" step="1" value="5" />
|
||||||
@@ -250,11 +247,9 @@
|
|||||||
<input type="range" id="top-angle-limit" min="0" max="90" step="1" value="0" />
|
<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" />
|
<input type="number" class="val" id="top-angle-limit-val" value="0" min="0" max="90" step="1" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Surface Exclusions -->
|
<!-- By Surface -->
|
||||||
<section class="panel-section">
|
<h3 class="panel-subsection-heading" id="excl-section-heading" data-i18n="sections.surfaceMasking" data-i18n-title="tooltips.surfaceMasking" title="Mask surfaces to control which areas receive displacement.">By Surface ⓘ</h3>
|
||||||
<h2 id="excl-section-heading" data-i18n="sections.surfaceMasking" data-i18n-title="tooltips.surfaceMasking" title="Mask surfaces to control which areas receive displacement.">Surface Masking ⓘ</h2>
|
|
||||||
<p id="excl-hint" class="excl-hint" style="display:none"></p>
|
<p id="excl-hint" class="excl-hint" style="display:none"></p>
|
||||||
|
|
||||||
<!-- Mode toggle: Exclude / Include Only -->
|
<!-- Mode toggle: Exclude / Include Only -->
|
||||||
|
|||||||
+115
-1
@@ -181,6 +181,119 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
|
smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Boundary falloff distance field ──────────────────────────────────────────
|
||||||
|
// When boundaryFalloff > 0, identify boundary positions (vertices adjacent to
|
||||||
|
// both masked and unmasked faces, or on the user-exclusion seam) and compute
|
||||||
|
// the Euclidean distance from every fully-textured vertex to its nearest
|
||||||
|
// boundary position. The result is falloffArr: Float64Array[uniqueCount]
|
||||||
|
// where 0 means "at the boundary" and 1 means "at or beyond the falloff distance".
|
||||||
|
const boundaryFalloff = settings.boundaryFalloff ?? 0;
|
||||||
|
let falloffArr = null;
|
||||||
|
|
||||||
|
if (boundaryFalloff > 0) {
|
||||||
|
// Build position lookup per unique vertex ID (first occurrence)
|
||||||
|
const idPosX = new Float64Array(uniqueCount);
|
||||||
|
const idPosY = new Float64Array(uniqueCount);
|
||||||
|
const idPosZ = new Float64Array(uniqueCount);
|
||||||
|
const idPosSeen = new Uint8Array(uniqueCount);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const vid = vertexId[i];
|
||||||
|
if (!idPosSeen[vid]) {
|
||||||
|
idPosSeen[vid] = 1;
|
||||||
|
idPosX[vid] = posAttr.getX(i);
|
||||||
|
idPosY[vid] = posAttr.getY(i);
|
||||||
|
idPosZ[vid] = posAttr.getZ(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundaryPositions = []; // [[x, y, z], ...]
|
||||||
|
|
||||||
|
// Collect boundary positions: vertices where maskedFrac is between 0 and 1,
|
||||||
|
// or that sit on the user-exclusion seam.
|
||||||
|
for (let id = 0; id < uniqueCount; id++) {
|
||||||
|
const mfTotal = maskedFracTotal[id];
|
||||||
|
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
|
||||||
|
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
|
||||||
|
if (isOnExclBoundary || (maskedFrac > 0 && maskedFrac < 1)) {
|
||||||
|
boundaryPositions.push([idPosX[id], idPosY[id], idPosZ[id]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaryPositions.length > 0) {
|
||||||
|
// Build a spatial grid of boundary positions for fast nearest-neighbor lookup
|
||||||
|
let gMinX = Infinity, gMinY = Infinity, gMinZ = Infinity;
|
||||||
|
let gMaxX = -Infinity, gMaxY = -Infinity, gMaxZ = -Infinity;
|
||||||
|
for (const bp of boundaryPositions) {
|
||||||
|
if (bp[0] < gMinX) gMinX = bp[0]; if (bp[0] > gMaxX) gMaxX = bp[0];
|
||||||
|
if (bp[1] < gMinY) gMinY = bp[1]; if (bp[1] > gMaxY) gMaxY = bp[1];
|
||||||
|
if (bp[2] < gMinZ) gMinZ = bp[2]; if (bp[2] > gMaxZ) gMaxZ = bp[2];
|
||||||
|
}
|
||||||
|
const gPad = boundaryFalloff + 1e-3;
|
||||||
|
gMinX -= gPad; gMinY -= gPad; gMinZ -= gPad;
|
||||||
|
gMaxX += gPad; gMaxY += gPad; gMaxZ += gPad;
|
||||||
|
|
||||||
|
const gRes = Math.max(4, Math.min(128, Math.ceil(Math.cbrt(boundaryPositions.length) * 2)));
|
||||||
|
const gDx = (gMaxX - gMinX) / gRes || 1;
|
||||||
|
const gDy = (gMaxY - gMinY) / gRes || 1;
|
||||||
|
const gDz = (gMaxZ - gMinZ) / gRes || 1;
|
||||||
|
const bGrid = new Map();
|
||||||
|
const bCellKey = (ix, iy, iz) => (ix * gRes + iy) * gRes + iz;
|
||||||
|
|
||||||
|
for (const bp of boundaryPositions) {
|
||||||
|
const ix = Math.max(0, Math.min(gRes - 1, Math.floor((bp[0] - gMinX) / gDx)));
|
||||||
|
const iy = Math.max(0, Math.min(gRes - 1, Math.floor((bp[1] - gMinY) / gDy)));
|
||||||
|
const iz = Math.max(0, Math.min(gRes - 1, Math.floor((bp[2] - gMinZ) / gDz)));
|
||||||
|
const ck = bCellKey(ix, iy, iz);
|
||||||
|
const cell = bGrid.get(ck);
|
||||||
|
if (cell) cell.push(bp); else bGrid.set(ck, [bp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many grid cells to search in each direction to cover boundaryFalloff distance
|
||||||
|
const searchX = Math.ceil(boundaryFalloff / gDx);
|
||||||
|
const searchY = Math.ceil(boundaryFalloff / gDy);
|
||||||
|
const searchZ = Math.ceil(boundaryFalloff / gDz);
|
||||||
|
|
||||||
|
falloffArr = new Float64Array(uniqueCount);
|
||||||
|
falloffArr.fill(1); // default: full displacement
|
||||||
|
for (let id = 0; id < uniqueCount; id++) {
|
||||||
|
const mfTotal = maskedFracTotal[id];
|
||||||
|
const maskedFrac = mfTotal > 0 ? maskedFracMasked[id] / mfTotal : 0;
|
||||||
|
const isOnExclBoundary = excludedPos && excludedPos[id] === 1;
|
||||||
|
// Only compute falloff for fully-textured, non-boundary positions
|
||||||
|
if (maskedFrac > 0 || isOnExclBoundary) continue;
|
||||||
|
|
||||||
|
const px = idPosX[id], py = idPosY[id], pz = idPosZ[id];
|
||||||
|
const cix = Math.max(0, Math.min(gRes - 1, Math.floor((px - gMinX) / gDx)));
|
||||||
|
const ciy = Math.max(0, Math.min(gRes - 1, Math.floor((py - gMinY) / gDy)));
|
||||||
|
const ciz = Math.max(0, Math.min(gRes - 1, Math.floor((pz - gMinZ) / gDz)));
|
||||||
|
|
||||||
|
let minDist2 = boundaryFalloff * boundaryFalloff;
|
||||||
|
for (let dix = -searchX; dix <= searchX; dix++) {
|
||||||
|
const nix = cix + dix;
|
||||||
|
if (nix < 0 || nix >= gRes) continue;
|
||||||
|
for (let diy = -searchY; diy <= searchY; diy++) {
|
||||||
|
const niy = ciy + diy;
|
||||||
|
if (niy < 0 || niy >= gRes) continue;
|
||||||
|
for (let diz = -searchZ; diz <= searchZ; diz++) {
|
||||||
|
const niz = ciz + diz;
|
||||||
|
if (niz < 0 || niz >= gRes) continue;
|
||||||
|
const cell = bGrid.get(bCellKey(nix, niy, niz));
|
||||||
|
if (!cell) continue;
|
||||||
|
for (const bp of cell) {
|
||||||
|
const dx = px - bp[0], dy = py - bp[1], dz = pz - bp[2];
|
||||||
|
const d2 = dx * dx + dy * dy + dz * dz;
|
||||||
|
if (d2 < minDist2) minDist2 = d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dist = Math.sqrt(minDist2);
|
||||||
|
const factor = Math.min(1, dist / boundaryFalloff);
|
||||||
|
falloffArr[id] = factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pass 2: sample displacement texture once per unique position ──────────
|
// ── Pass 2: sample displacement texture once per unique position ──────────
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -271,7 +384,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const mfTotal = maskedFracTotal[vid];
|
const mfTotal = maskedFracTotal[vid];
|
||||||
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
|
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
|
||||||
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
|
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
|
||||||
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
|
const falloffFactor = falloffArr ? falloffArr[vid] : 1.0;
|
||||||
|
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : falloffFactor * (1 - maskedFrac) * centeredGrey * settings.amplitude;
|
||||||
|
|
||||||
const newX = tmpPos.x + smoothNrmX[vid] * disp;
|
const newX = tmpPos.x + smoothNrmX[vid] * disp;
|
||||||
const newY = tmpPos.y + smoothNrmY[vid] * disp;
|
const newY = tmpPos.y + smoothNrmY[vid] * disp;
|
||||||
|
|||||||
+1040
-242
File diff suppressed because it is too large
Load Diff
+436
-38
@@ -12,7 +12,7 @@ import { decimate } from './decimation.js';
|
|||||||
import { exportSTL } from './exporter.js';
|
import { exportSTL } from './exporter.js';
|
||||||
import { buildAdjacency, bucketFill,
|
import { buildAdjacency, bucketFill,
|
||||||
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
|
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
|
||||||
import { t, initLang, setLang, getLang, applyTranslations } from './i18n.js';
|
import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js';
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ let previewMaterial = null;
|
|||||||
let isExporting = false;
|
let isExporting = false;
|
||||||
let previewDebounce = null;
|
let previewDebounce = null;
|
||||||
|
|
||||||
|
// Boundary edge data texture for per-fragment falloff in bump-only preview
|
||||||
|
let _boundaryEdgeTex = null;
|
||||||
|
let _boundaryEdgeCount = 0;
|
||||||
|
let _falloffDirty = true; // recompute falloff on next updateFaceMask
|
||||||
|
let _falloffGeometry = null; // geometry the falloff was last computed for
|
||||||
|
|
||||||
// ── Exclusion state ───────────────────────────────────────────────────────────
|
// ── Exclusion state ───────────────────────────────────────────────────────────
|
||||||
let excludedFaces = new Set(); // triangle indices in currentGeometry
|
let excludedFaces = new Set(); // triangle indices in currentGeometry
|
||||||
let triangleAdjacency = null; // Array from buildAdjacency
|
let triangleAdjacency = null; // Array from buildAdjacency
|
||||||
@@ -62,6 +68,7 @@ const settings = {
|
|||||||
seamBandWidth: 0.5,
|
seamBandWidth: 0.5,
|
||||||
textureSmoothing: 0,
|
textureSmoothing: 0,
|
||||||
capAngle: 20,
|
capAngle: 20,
|
||||||
|
boundaryFalloff: 0,
|
||||||
symmetricDisplacement: false,
|
symmetricDisplacement: false,
|
||||||
useDisplacement: false,
|
useDisplacement: false,
|
||||||
};
|
};
|
||||||
@@ -207,6 +214,8 @@ const textureSmoothingVal = document.getElementById('texture-smoothing-val');
|
|||||||
const capAngleSlider = document.getElementById('cap-angle');
|
const capAngleSlider = document.getElementById('cap-angle');
|
||||||
const capAngleVal = document.getElementById('cap-angle-val');
|
const capAngleVal = document.getElementById('cap-angle-val');
|
||||||
const capAngleRow = document.getElementById('cap-angle-row');
|
const capAngleRow = document.getElementById('cap-angle-row');
|
||||||
|
const boundaryFalloffSlider = document.getElementById('boundary-falloff');
|
||||||
|
const boundaryFalloffVal = document.getElementById('boundary-falloff-val');
|
||||||
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
const symmetricDispToggle = document.getElementById('symmetric-displacement');
|
||||||
const dispPreviewToggle = document.getElementById('displacement-preview');
|
const dispPreviewToggle = document.getElementById('displacement-preview');
|
||||||
|
|
||||||
@@ -245,6 +254,9 @@ const imprintLink = document.getElementById('imprint-link');
|
|||||||
const imprintOverlay = document.getElementById('imprint-overlay');
|
const imprintOverlay = document.getElementById('imprint-overlay');
|
||||||
const imprintClose = document.getElementById('imprint-close');
|
const imprintClose = document.getElementById('imprint-close');
|
||||||
|
|
||||||
|
// ── Language selector DOM refs ────────────────────────────────────────────────────
|
||||||
|
const languageSelector = document.querySelector('.lang-seg');
|
||||||
|
|
||||||
// ── Scale slider log helpers ──────────────────────────────────────────────────
|
// ── Scale slider log helpers ──────────────────────────────────────────────────
|
||||||
// Slider stores 0–1000; actual scale spans 0.05–10 on a log axis.
|
// Slider stores 0–1000; actual scale spans 0.05–10 on a log axis.
|
||||||
// Middle position 500 → scale ~0.71 (log midpoint between 0.05 and 10).
|
// Middle position 500 → scale ~0.71 (log midpoint between 0.05 and 10).
|
||||||
@@ -271,15 +283,46 @@ initViewer(canvas);
|
|||||||
// Apply saved theme to 3D viewport on startup
|
// Apply saved theme to 3D viewport on startup
|
||||||
setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light');
|
setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light');
|
||||||
|
|
||||||
|
// Populate the language selector
|
||||||
|
function populateLanguageSelector() {
|
||||||
|
if (!languageSelector) return;
|
||||||
|
languageSelector.innerHTML = '';
|
||||||
|
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'lang-dropdown';
|
||||||
|
|
||||||
|
for (const langKey in TRANSLATIONS) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = langKey;
|
||||||
|
opt.className = 'lang-option';
|
||||||
|
opt.textContent = TRANSLATIONS[langKey]['lang.name'] || langKey.toUpperCase();
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
setLang(e.target.value);
|
||||||
|
// Re-translate <option> elements (innerHTML won't reach these)
|
||||||
|
document.querySelectorAll('select[id="mapping-mode"] option[data-i18n-opt]').forEach(opt => {
|
||||||
|
opt.textContent = t(opt.dataset.i18nOpt);
|
||||||
|
});
|
||||||
|
// Refresh dynamic count text to current language
|
||||||
|
if (currentGeometry) refreshExclusionOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
languageSelector.appendChild(select);
|
||||||
|
}
|
||||||
|
populateLanguageSelector();
|
||||||
|
|
||||||
// Initialise language (reads localStorage / browser preference, applies translations)
|
// Initialise language (reads localStorage / browser preference, applies translations)
|
||||||
initLang();
|
initLang();
|
||||||
|
|
||||||
// Sync lang buttons to current language
|
// Sync lang dropdown to current language
|
||||||
(function() {
|
(function() {
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
const select = languageSelector.querySelector('select');
|
||||||
btn.classList.toggle('active', btn.dataset.langCode === lang);
|
if (select) {
|
||||||
});
|
select.value = lang;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Theme toggle
|
// Theme toggle
|
||||||
@@ -380,22 +423,6 @@ function trapFocus(overlay) {
|
|||||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
// ── Language toggle ──
|
|
||||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const lang = btn.dataset.langCode;
|
|
||||||
setLang(lang);
|
|
||||||
document.querySelectorAll('.lang-btn').forEach(b =>
|
|
||||||
b.classList.toggle('active', b.dataset.langCode === lang));
|
|
||||||
// Re-translate <option> elements (innerHTML won't reach these)
|
|
||||||
document.querySelectorAll('select[id="mapping-mode"] option[data-i18n-opt]').forEach(opt => {
|
|
||||||
opt.textContent = t(opt.dataset.i18nOpt);
|
|
||||||
});
|
|
||||||
// Refresh dynamic count text to current language
|
|
||||||
if (currentGeometry) refreshExclusionOverlay();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Model loading ──
|
// ── Model loading ──
|
||||||
stlFileInput.addEventListener('change', (e) => {
|
stlFileInput.addEventListener('change', (e) => {
|
||||||
if (e.target.files[0]) handleModelFile(e.target.files[0]);
|
if (e.target.files[0]) handleModelFile(e.target.files[0]);
|
||||||
@@ -479,10 +506,11 @@ function wireEvents() {
|
|||||||
linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); });
|
linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); });
|
||||||
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); });
|
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); });
|
||||||
amplitudeVal.addEventListener('change', checkAmplitudeWarning);
|
amplitudeVal.addEventListener('change', checkAmplitudeWarning);
|
||||||
|
linkSlider(boundaryFalloffSlider, boundaryFalloffVal, v => { settings.boundaryFalloff = v; _falloffDirty = true; return v.toFixed(1); });
|
||||||
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(2); }, false);
|
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(2); }, 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(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; _falloffDirty = true; return v; });
|
||||||
linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; });
|
linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; _falloffDirty = true; return v; });
|
||||||
linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); });
|
linkSlider(seamBlendSlider, seamBlendVal, v => { settings.mappingBlend = v; return v.toFixed(2); });
|
||||||
linkSlider(seamBandWidthSlider, seamBandWidthVal, v => { settings.seamBandWidth = v; return v.toFixed(2); });
|
linkSlider(seamBandWidthSlider, seamBandWidthVal, v => { settings.seamBandWidth = v; return v.toFixed(2); });
|
||||||
linkSlider(textureSmoothingSlider, textureSmoothingVal, v => { settings.textureSmoothing = v; return v.toFixed(1); });
|
linkSlider(textureSmoothingSlider, textureSmoothingVal, v => { settings.textureSmoothing = v; return v.toFixed(1); });
|
||||||
@@ -718,7 +746,7 @@ function wireEvents() {
|
|||||||
if (placeOnFaceActive && currentGeometry) { updatePlaceOnFaceHover(ev); return; }
|
if (placeOnFaceActive && currentGeometry) { updatePlaceOnFaceHover(ev); return; }
|
||||||
if (exclusionTool === 'brush') {
|
if (exclusionTool === 'brush') {
|
||||||
updateBrushCursor(ev);
|
updateBrushCursor(ev);
|
||||||
if (brushIsRadius && !isPainting && currentGeometry) updateBrushHover(ev);
|
if (!isPainting && currentGeometry) updateBrushHover(ev);
|
||||||
_updateShiftLinePreview(ev);
|
_updateShiftLinePreview(ev);
|
||||||
} else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) {
|
} else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) {
|
||||||
updateBucketHover(ev);
|
updateBucketHover(ev);
|
||||||
@@ -809,6 +837,14 @@ function setExclusionTool(tool) {
|
|||||||
if (!exclusionTool) {
|
if (!exclusionTool) {
|
||||||
isPainting = false;
|
isPainting = false;
|
||||||
getControls().enabled = true;
|
getControls().enabled = true;
|
||||||
|
// Recompute boundary falloff now that masking is done
|
||||||
|
if (_falloffDirty && currentGeometry) {
|
||||||
|
const activeGeo = (precisionMaskingEnabled && precisionGeometry)
|
||||||
|
? precisionGeometry
|
||||||
|
: (settings.useDisplacement && dispPreviewGeometry)
|
||||||
|
? dispPreviewGeometry : currentGeometry;
|
||||||
|
updateFaceMask(activeGeo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,8 +1258,8 @@ function handlePlaceOnFaceClick(e) {
|
|||||||
buildSpatialGrid(triangleCentroids, currentGeometry.attributes.position.count / 3, currentBounds);
|
buildSpatialGrid(triangleCentroids, currentGeometry.attributes.position.count / 3, currentBounds);
|
||||||
|
|
||||||
// Update edge length for new bounds
|
// Update edge length for new bounds
|
||||||
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
|
const diag = Math.sqrt(currentBounds.size.x ** 2 + currentBounds.size.y ** 2 + currentBounds.size.z ** 2);
|
||||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 300).toFixed(2)));
|
||||||
settings.refineLength = defaultEdge;
|
settings.refineLength = defaultEdge;
|
||||||
refineLenSlider.value = defaultEdge;
|
refineLenSlider.value = defaultEdge;
|
||||||
refineLenVal.value = defaultEdge;
|
refineLenVal.value = defaultEdge;
|
||||||
@@ -1258,12 +1294,11 @@ function refreshExclusionOverlay() {
|
|||||||
const overlayGeo = usePrecision ? precisionGeometry : currentGeometry;
|
const overlayGeo = usePrecision ? precisionGeometry : currentGeometry;
|
||||||
const overlayFaceSet = usePrecision ? precisionExcludedFaces : excludedFaces;
|
const overlayFaceSet = usePrecision ? precisionExcludedFaces : excludedFaces;
|
||||||
|
|
||||||
if (selectionMode) {
|
_falloffDirty = true;
|
||||||
const maskGeo = buildExclusionOverlayGeo(overlayGeo, overlayFaceSet, true);
|
|
||||||
setExclusionOverlay(maskGeo, 0x8ab4d4, 0.96);
|
// Never show the flat-coloured MeshLambertMaterial overlay — the custom
|
||||||
} else {
|
// shader handles mask visualisation with smooth, view-dependent shading.
|
||||||
setExclusionOverlay(buildExclusionOverlayGeo(overlayGeo, overlayFaceSet), 0xff6600);
|
setExclusionOverlay(null);
|
||||||
}
|
|
||||||
const n = usePrecision ? precisionExcludedFaces.size : excludedFaces.size;
|
const n = usePrecision ? precisionExcludedFaces.size : excludedFaces.size;
|
||||||
exclCount.textContent = selectionMode
|
exclCount.textContent = selectionMode
|
||||||
? t(n === 1 ? 'excl.faceSelected' : 'excl.facesSelected', { n: n.toLocaleString() })
|
? t(n === 1 ? 'excl.faceSelected' : 'excl.facesSelected', { n: n.toLocaleString() })
|
||||||
@@ -1482,8 +1517,8 @@ function loadDefaultCube() {
|
|||||||
settings.offsetV = 0; offsetVSlider.value = 0; offsetVVal.value = 0;
|
settings.offsetV = 0; offsetVSlider.value = 0; offsetVVal.value = 0;
|
||||||
triLimitWarning.classList.add('hidden');
|
triLimitWarning.classList.add('hidden');
|
||||||
|
|
||||||
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
|
const diag = Math.sqrt(currentBounds.size.x ** 2 + currentBounds.size.y ** 2 + currentBounds.size.z ** 2);
|
||||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 250).toFixed(2)));
|
||||||
settings.refineLength = defaultEdge;
|
settings.refineLength = defaultEdge;
|
||||||
refineLenSlider.value = defaultEdge;
|
refineLenSlider.value = defaultEdge;
|
||||||
refineLenVal.value = defaultEdge;
|
refineLenVal.value = defaultEdge;
|
||||||
@@ -1582,9 +1617,9 @@ async function handleModelFile(file) {
|
|||||||
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
|
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
|
||||||
triLimitWarning.classList.add('hidden');
|
triLimitWarning.classList.add('hidden');
|
||||||
|
|
||||||
// Default edge length = 1/200 of the largest bounding box dimension
|
// Default edge length = 1/250 of the bounding box diagonal
|
||||||
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
|
const diag = Math.sqrt(bounds.size.x ** 2 + bounds.size.y ** 2 + bounds.size.z ** 2);
|
||||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
const defaultEdge = Math.max(0.05, Math.min(5.0, +(diag / 250).toFixed(2)));
|
||||||
settings.refineLength = defaultEdge;
|
settings.refineLength = defaultEdge;
|
||||||
refineLenSlider.value = defaultEdge;
|
refineLenSlider.value = defaultEdge;
|
||||||
refineLenVal.value = defaultEdge;
|
refineLenVal.value = defaultEdge;
|
||||||
@@ -1669,9 +1704,360 @@ function updateFaceMask(geometry) {
|
|||||||
if (!geometry.attributes.faceNormal) {
|
if (!geometry.attributes.faceNormal) {
|
||||||
addFaceNormals(geometry);
|
addFaceNormals(geometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure falloff attributes exist so the shader doesn't read 0.0 for missing
|
||||||
|
// attributes (which would make totalMask = 0 → entire model appears masked).
|
||||||
|
// This matters when a fresh geometry is displayed while the masking tool is
|
||||||
|
// active (e.g. entering precision mode) because the expensive recomputation
|
||||||
|
// below is intentionally skipped during active masking.
|
||||||
|
if (!geometry.attributes.boundaryFalloffAttr) {
|
||||||
|
const arr = new Float32Array(posCount);
|
||||||
|
arr.fill(1.0);
|
||||||
|
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(arr, 1));
|
||||||
|
}
|
||||||
|
if (!geometry.attributes.boundaryMaskTypeAttr) {
|
||||||
|
const arr = new Float32Array(posCount);
|
||||||
|
arr.fill(1.0);
|
||||||
|
geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(arr, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip expensive per-vertex falloff and boundary edge recomputation while
|
||||||
|
// actively masking; both will be recalculated when the masking tool is
|
||||||
|
// deactivated (in setExclusionTool → updateFaceMask with exclusionTool=null).
|
||||||
|
if (!exclusionTool && (_falloffDirty || geometry !== _falloffGeometry)) {
|
||||||
|
computeBoundaryFalloffAttr(geometry, maskArr);
|
||||||
|
computeBoundaryEdges(geometry, maskArr);
|
||||||
|
_falloffDirty = false;
|
||||||
|
_falloffGeometry = geometry;
|
||||||
|
}
|
||||||
|
syncBoundaryEdgeUniforms();
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a per-vertex `boundaryFalloffAttr` float attribute on the geometry.
|
||||||
|
* Vertices near the boundary between masked and non-masked regions get values
|
||||||
|
* ramping from 0 (at boundary) to 1 (at or beyond boundaryFalloff distance).
|
||||||
|
* The shader multiplies displacement/bump by this attribute.
|
||||||
|
*
|
||||||
|
* @param {THREE.BufferGeometry} geometry
|
||||||
|
* @param {Float32Array} userMaskArr – per-vertex user-exclusion mask from updateFaceMask
|
||||||
|
*/
|
||||||
|
function computeBoundaryFalloffAttr(geometry, userMaskArr) {
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const posCount = posAttr.count;
|
||||||
|
const triCount = posCount / 3;
|
||||||
|
const falloff = settings.boundaryFalloff ?? 0;
|
||||||
|
|
||||||
|
// Reuse existing attribute buffers when sizes match to avoid Three.js
|
||||||
|
// WebGL binding state cache issues when replacing attribute objects on
|
||||||
|
// a geometry that is already attached to a rendered mesh.
|
||||||
|
const existingFalloff = geometry.getAttribute('boundaryFalloffAttr');
|
||||||
|
const reuseFalloff = existingFalloff && existingFalloff.array.length === posCount;
|
||||||
|
const falloffArr = reuseFalloff ? existingFalloff.array : new Float32Array(posCount);
|
||||||
|
falloffArr.fill(1.0);
|
||||||
|
|
||||||
|
const existingType = geometry.getAttribute('boundaryMaskTypeAttr');
|
||||||
|
const reuseType = existingType && existingType.array.length === posCount;
|
||||||
|
const maskTypeArr = reuseType ? existingType.array : new Float32Array(posCount);
|
||||||
|
maskTypeArr.fill(1.0);
|
||||||
|
|
||||||
|
if (falloff <= 0) {
|
||||||
|
if (reuseFalloff) existingFalloff.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
|
||||||
|
if (reuseType) existingType.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute per-face combined mask (angle masking + user exclusion).
|
||||||
|
// Mirrors the vertex shader logic so the preview boundary matches export.
|
||||||
|
const faceNrmAttr = geometry.attributes.faceNormal;
|
||||||
|
const faceMask = new Float32Array(triCount); // 0 = masked, 1 = textured
|
||||||
|
const isUserMasked = new Uint8Array(triCount); // 1 if user-excluded
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
const userVal = userMaskArr[t * 3]; // same for all 3 verts of this face
|
||||||
|
if (userVal < 0.5) { faceMask[t] = 0; isUserMasked[t] = 1; continue; }
|
||||||
|
|
||||||
|
let angleMask = 1.0;
|
||||||
|
if (faceNrmAttr) {
|
||||||
|
const fnz = faceNrmAttr.getZ(t * 3);
|
||||||
|
const fnx = faceNrmAttr.getX(t * 3);
|
||||||
|
const fny = faceNrmAttr.getY(t * 3);
|
||||||
|
const len = Math.sqrt(fnx * fnx + fny * fny + fnz * fnz);
|
||||||
|
const nz = len > 1e-6 ? fnz / len : 0;
|
||||||
|
const surfaceAngle = Math.acos(Math.min(1, Math.abs(nz))) * (180 / Math.PI);
|
||||||
|
if (nz < 0 && settings.bottomAngleLimit >= 1)
|
||||||
|
angleMask = surfaceAngle > settings.bottomAngleLimit ? 1.0 : 0.0;
|
||||||
|
if (nz >= 0 && settings.topAngleLimit >= 1)
|
||||||
|
angleMask = Math.min(angleMask, surfaceAngle > settings.topAngleLimit ? 1.0 : 0.0);
|
||||||
|
}
|
||||||
|
faceMask[t] = angleMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-unique-position map and identify boundary positions.
|
||||||
|
const QUANT = 1e4;
|
||||||
|
const posKey = (x, y, z) =>
|
||||||
|
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
||||||
|
|
||||||
|
const posFromKey = new Map(); // posKey → [x, y, z]
|
||||||
|
// Per-position: [maskedArea, totalArea] to find boundary vertices
|
||||||
|
const maskFracMap = new Map();
|
||||||
|
const userMaskAreaMap = new Map(); // posKey → area of user-masked faces
|
||||||
|
const tmpV = new THREE.Vector3();
|
||||||
|
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
|
||||||
|
const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3();
|
||||||
|
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
vA.fromBufferAttribute(posAttr, t * 3);
|
||||||
|
vB.fromBufferAttribute(posAttr, t * 3 + 1);
|
||||||
|
vC.fromBufferAttribute(posAttr, t * 3 + 2);
|
||||||
|
e1.subVectors(vB, vA);
|
||||||
|
e2.subVectors(vC, vA);
|
||||||
|
fn.crossVectors(e1, e2);
|
||||||
|
const area = fn.length();
|
||||||
|
const masked = faceMask[t] < 0.5;
|
||||||
|
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
tmpV.fromBufferAttribute(posAttr, t * 3 + v);
|
||||||
|
const k = posKey(tmpV.x, tmpV.y, tmpV.z);
|
||||||
|
if (!posFromKey.has(k)) posFromKey.set(k, [tmpV.x, tmpV.y, tmpV.z]);
|
||||||
|
const mf = maskFracMap.get(k);
|
||||||
|
if (mf) {
|
||||||
|
if (masked) mf[0] += area;
|
||||||
|
mf[1] += area;
|
||||||
|
} else {
|
||||||
|
maskFracMap.set(k, [masked ? area : 0, area]);
|
||||||
|
}
|
||||||
|
// Track user-mask area per position to classify boundary type
|
||||||
|
if (isUserMasked[t]) {
|
||||||
|
const prev = userMaskAreaMap.get(k) || 0;
|
||||||
|
userMaskAreaMap.set(k, prev + area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary positions: shared between masked and non-masked faces.
|
||||||
|
// Each entry: [x, y, z, maskType] where maskType 0 = user, 1 = angle.
|
||||||
|
const boundaryPositions = [];
|
||||||
|
for (const [k, pos] of posFromKey) {
|
||||||
|
const mf = maskFracMap.get(k);
|
||||||
|
const frac = mf[1] > 0 ? mf[0] / mf[1] : 0;
|
||||||
|
if (frac > 0 && frac < 1) {
|
||||||
|
const userArea = userMaskAreaMap.get(k) || 0;
|
||||||
|
boundaryPositions.push([pos[0], pos[1], pos[2], userArea > 0 ? 0 : 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaryPositions.length === 0) {
|
||||||
|
if (reuseFalloff) existingFalloff.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
|
||||||
|
if (reuseType) existingType.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spatial grid of boundary positions for fast nearest-neighbor search
|
||||||
|
let gMinX = Infinity, gMinY = Infinity, gMinZ = Infinity;
|
||||||
|
let gMaxX = -Infinity, gMaxY = -Infinity, gMaxZ = -Infinity;
|
||||||
|
for (const bp of boundaryPositions) {
|
||||||
|
if (bp[0] < gMinX) gMinX = bp[0]; if (bp[0] > gMaxX) gMaxX = bp[0];
|
||||||
|
if (bp[1] < gMinY) gMinY = bp[1]; if (bp[1] > gMaxY) gMaxY = bp[1];
|
||||||
|
if (bp[2] < gMinZ) gMinZ = bp[2]; if (bp[2] > gMaxZ) gMaxZ = bp[2];
|
||||||
|
}
|
||||||
|
const gPad = falloff + 1e-3;
|
||||||
|
gMinX -= gPad; gMinY -= gPad; gMinZ -= gPad;
|
||||||
|
gMaxX += gPad; gMaxY += gPad; gMaxZ += gPad;
|
||||||
|
|
||||||
|
const gRes = Math.max(4, Math.min(128, Math.ceil(Math.cbrt(boundaryPositions.length) * 2)));
|
||||||
|
const gDx = (gMaxX - gMinX) / gRes || 1;
|
||||||
|
const gDy = (gMaxY - gMinY) / gRes || 1;
|
||||||
|
const gDz = (gMaxZ - gMinZ) / gRes || 1;
|
||||||
|
const bGrid = new Map();
|
||||||
|
const bCellKey = (ix, iy, iz) => (ix * gRes + iy) * gRes + iz;
|
||||||
|
|
||||||
|
for (const bp of boundaryPositions) {
|
||||||
|
const ix = Math.max(0, Math.min(gRes - 1, Math.floor((bp[0] - gMinX) / gDx)));
|
||||||
|
const iy = Math.max(0, Math.min(gRes - 1, Math.floor((bp[1] - gMinY) / gDy)));
|
||||||
|
const iz = Math.max(0, Math.min(gRes - 1, Math.floor((bp[2] - gMinZ) / gDz)));
|
||||||
|
const ck = bCellKey(ix, iy, iz);
|
||||||
|
const cell = bGrid.get(ck);
|
||||||
|
if (cell) cell.push(bp); else bGrid.set(ck, [bp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchX = Math.ceil(falloff / gDx);
|
||||||
|
const searchY = Math.ceil(falloff / gDy);
|
||||||
|
const searchZ = Math.ceil(falloff / gDz);
|
||||||
|
|
||||||
|
// Compute per-unique-position falloff factor and mask type
|
||||||
|
const falloffCache = new Map(); // posKey → factor [0,1]
|
||||||
|
const maskTypeCache = new Map(); // posKey → 0 (user mask) or 1 (angle mask)
|
||||||
|
for (const [k, pos] of posFromKey) {
|
||||||
|
const mf = maskFracMap.get(k);
|
||||||
|
const frac = mf[1] > 0 ? mf[0] / mf[1] : 0;
|
||||||
|
if (frac >= 1) continue; // fully masked vertex — keep 1.0 (mask zeroes it anyway)
|
||||||
|
// Boundary vertices (shared between masked and unmasked faces) are AT
|
||||||
|
// the boundary → distance 0 → falloff factor 0.
|
||||||
|
if (frac > 0) {
|
||||||
|
falloffCache.set(k, 0);
|
||||||
|
const userArea = userMaskAreaMap.get(k) || 0;
|
||||||
|
maskTypeCache.set(k, userArea > 0 ? 0 : 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const px = pos[0], py = pos[1], pz = pos[2];
|
||||||
|
const cix = Math.max(0, Math.min(gRes - 1, Math.floor((px - gMinX) / gDx)));
|
||||||
|
const ciy = Math.max(0, Math.min(gRes - 1, Math.floor((py - gMinY) / gDy)));
|
||||||
|
const ciz = Math.max(0, Math.min(gRes - 1, Math.floor((pz - gMinZ) / gDz)));
|
||||||
|
|
||||||
|
let minDist2 = falloff * falloff;
|
||||||
|
let nearestType = 1; // default: angle mask
|
||||||
|
for (let dix = -searchX; dix <= searchX; dix++) {
|
||||||
|
const nix = cix + dix;
|
||||||
|
if (nix < 0 || nix >= gRes) continue;
|
||||||
|
for (let diy = -searchY; diy <= searchY; diy++) {
|
||||||
|
const niy = ciy + diy;
|
||||||
|
if (niy < 0 || niy >= gRes) continue;
|
||||||
|
for (let diz = -searchZ; diz <= searchZ; diz++) {
|
||||||
|
const niz = ciz + diz;
|
||||||
|
if (niz < 0 || niz >= gRes) continue;
|
||||||
|
const cell = bGrid.get(bCellKey(nix, niy, niz));
|
||||||
|
if (!cell) continue;
|
||||||
|
for (const bp of cell) {
|
||||||
|
const dx = px - bp[0], dy = py - bp[1], dz = pz - bp[2];
|
||||||
|
const d2 = dx * dx + dy * dy + dz * dz;
|
||||||
|
if (d2 < minDist2) { minDist2 = d2; nearestType = bp[3]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dist = Math.sqrt(minDist2);
|
||||||
|
const factor = Math.min(1, dist / falloff);
|
||||||
|
if (factor < 1) {
|
||||||
|
falloffCache.set(k, factor);
|
||||||
|
maskTypeCache.set(k, nearestType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write per-vertex attributes
|
||||||
|
for (let i = 0; i < posCount; i++) {
|
||||||
|
tmpV.fromBufferAttribute(posAttr, i);
|
||||||
|
const k = posKey(tmpV.x, tmpV.y, tmpV.z);
|
||||||
|
if (falloffCache.has(k)) falloffArr[i] = falloffCache.get(k);
|
||||||
|
if (maskTypeCache.has(k)) maskTypeArr[i] = maskTypeCache.get(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reuseFalloff) existingFalloff.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
|
||||||
|
if (reuseType) existingType.needsUpdate = true;
|
||||||
|
else geometry.setAttribute('boundaryMaskTypeAttr', new THREE.Float32BufferAttribute(maskTypeArr, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute boundary edge segments between masked and non-masked faces and
|
||||||
|
* pack them into a DataTexture for per-fragment distance queries in the
|
||||||
|
* bump-only preview shader. Each edge is stored as two RGBA texels
|
||||||
|
* (endpoint A xyz, endpoint B xyz).
|
||||||
|
*/
|
||||||
|
function computeBoundaryEdges(geometry, userMaskArr) {
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const posCount = posAttr.count;
|
||||||
|
const triCount = posCount / 3;
|
||||||
|
const falloff = settings.boundaryFalloff ?? 0;
|
||||||
|
|
||||||
|
if (_boundaryEdgeTex) { _boundaryEdgeTex.dispose(); _boundaryEdgeTex = null; }
|
||||||
|
_boundaryEdgeCount = 0;
|
||||||
|
if (falloff <= 0) return;
|
||||||
|
|
||||||
|
const faceNrmAttr = geometry.attributes.faceNormal;
|
||||||
|
const faceMaskBool = new Uint8Array(triCount);
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
if (userMaskArr[t * 3] < 0.5) { faceMaskBool[t] = 0; continue; }
|
||||||
|
let angleMask = 1.0;
|
||||||
|
if (faceNrmAttr) {
|
||||||
|
const fnx = faceNrmAttr.getX(t * 3);
|
||||||
|
const fny = faceNrmAttr.getY(t * 3);
|
||||||
|
const fnz = faceNrmAttr.getZ(t * 3);
|
||||||
|
const len = Math.sqrt(fnx * fnx + fny * fny + fnz * fnz);
|
||||||
|
const nz = len > 1e-6 ? fnz / len : 0;
|
||||||
|
const surfAngle = Math.acos(Math.min(1, Math.abs(nz))) * (180 / Math.PI);
|
||||||
|
if (nz < 0 && settings.bottomAngleLimit >= 1)
|
||||||
|
angleMask = surfAngle > settings.bottomAngleLimit ? 1.0 : 0.0;
|
||||||
|
if (nz >= 0 && settings.topAngleLimit >= 1)
|
||||||
|
angleMask = Math.min(angleMask, surfAngle > settings.topAngleLimit ? 1.0 : 0.0);
|
||||||
|
}
|
||||||
|
faceMaskBool[t] = angleMask > 0.5 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUANT = 1e4;
|
||||||
|
const pk = (x, y, z) =>
|
||||||
|
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
||||||
|
const ek = (k1, k2) => k1 < k2 ? k1 + '|' + k2 : k2 + '|' + k1;
|
||||||
|
const tmpV = new THREE.Vector3();
|
||||||
|
|
||||||
|
const edgeFaces = new Map();
|
||||||
|
const edgePos = new Map();
|
||||||
|
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
const keys = [], pts = [];
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
tmpV.fromBufferAttribute(posAttr, t * 3 + v);
|
||||||
|
keys.push(pk(tmpV.x, tmpV.y, tmpV.z));
|
||||||
|
pts.push([tmpV.x, tmpV.y, tmpV.z]);
|
||||||
|
}
|
||||||
|
for (let e = 0; e < 3; e++) {
|
||||||
|
const edgeKey = ek(keys[e], keys[(e + 1) % 3]);
|
||||||
|
const list = edgeFaces.get(edgeKey);
|
||||||
|
if (list) list.push(t);
|
||||||
|
else {
|
||||||
|
edgeFaces.set(edgeKey, [t]);
|
||||||
|
edgePos.set(edgeKey, [pts[e], pts[(e + 1) % 3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EDGES = 64;
|
||||||
|
const edges = [];
|
||||||
|
for (const [key, faces] of edgeFaces) {
|
||||||
|
if (edges.length >= MAX_EDGES) break;
|
||||||
|
let hasMasked = false, hasTextured = false;
|
||||||
|
for (const f of faces) {
|
||||||
|
if (faceMaskBool[f] === 0) hasMasked = true;
|
||||||
|
else hasTextured = true;
|
||||||
|
if (hasMasked && hasTextured) break;
|
||||||
|
}
|
||||||
|
if (hasMasked && hasTextured) edges.push(edgePos.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edges.length === 0) return;
|
||||||
|
|
||||||
|
const texWidth = edges.length * 2;
|
||||||
|
const data = new Float32Array(texWidth * 4);
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
const [a, b] = edges[i];
|
||||||
|
const off = i * 8;
|
||||||
|
data[off] = a[0]; data[off + 1] = a[1]; data[off + 2] = a[2]; data[off + 3] = 0;
|
||||||
|
data[off + 4] = b[0]; data[off + 5] = b[1]; data[off + 6] = b[2]; data[off + 7] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_boundaryEdgeTex = new THREE.DataTexture(data, texWidth, 1, THREE.RGBAFormat, THREE.FloatType);
|
||||||
|
_boundaryEdgeTex.minFilter = THREE.NearestFilter;
|
||||||
|
_boundaryEdgeTex.magFilter = THREE.NearestFilter;
|
||||||
|
_boundaryEdgeTex.needsUpdate = true;
|
||||||
|
_boundaryEdgeCount = edges.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBoundaryEdgeUniforms() {
|
||||||
|
if (!previewMaterial || !previewMaterial.uniforms.boundaryEdgeTex) return;
|
||||||
|
const u = previewMaterial.uniforms;
|
||||||
|
if (_boundaryEdgeTex) {
|
||||||
|
u.boundaryEdgeTex.value = _boundaryEdgeTex;
|
||||||
|
u.boundaryEdgeTexWidth.value = _boundaryEdgeTex.image.width;
|
||||||
|
}
|
||||||
|
u.boundaryEdgeCount.value = _boundaryEdgeCount;
|
||||||
|
u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a mapping from each subdivided face to its nearest original face
|
* Build a mapping from each subdivided face to its nearest original face
|
||||||
* using a grid-accelerated nearest-centroid lookup, with face normal
|
* using a grid-accelerated nearest-centroid lookup, with face normal
|
||||||
@@ -1887,6 +2273,7 @@ function updatePreview() {
|
|||||||
updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings);
|
updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncBoundaryEdgeUniforms();
|
||||||
exportBtn.disabled = false;
|
exportBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2046,7 +2433,6 @@ function deactivatePrecisionMasking() {
|
|||||||
excludedFaces = precisionExcludedFaces;
|
excludedFaces = precisionExcludedFaces;
|
||||||
|
|
||||||
// Update mesh info display
|
// Update mesh info display
|
||||||
const triCount = getTriangleCount(currentGeometry);
|
|
||||||
const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
||||||
const sx = currentBounds.size.x.toFixed(2);
|
const sx = currentBounds.size.x.toFixed(2);
|
||||||
const sy = currentBounds.size.y.toFixed(2);
|
const sy = currentBounds.size.y.toFixed(2);
|
||||||
@@ -2138,6 +2524,18 @@ async function refreshPrecisionMesh() {
|
|||||||
// Swap display mesh to refined geometry
|
// Swap display mesh to refined geometry
|
||||||
setMeshGeometry(precisionGeometry);
|
setMeshGeometry(precisionGeometry);
|
||||||
updateFaceMask(precisionGeometry);
|
updateFaceMask(precisionGeometry);
|
||||||
|
// Force per-vertex falloff computation on the fresh geometry even though
|
||||||
|
// the masking tool is still active – updateFaceMask only computes boundary
|
||||||
|
// edges during painting; the full vertex-level falloff is deferred until
|
||||||
|
// the tool is deactivated, but we need it now for the initial state.
|
||||||
|
{
|
||||||
|
const maskAttr = precisionGeometry.getAttribute('faceMask');
|
||||||
|
if (maskAttr) {
|
||||||
|
computeBoundaryFalloffAttr(precisionGeometry, maskAttr.array);
|
||||||
|
_falloffDirty = false;
|
||||||
|
_falloffGeometry = precisionGeometry;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (precisionExcludedFaces.size > 0) refreshExclusionOverlay();
|
if (precisionExcludedFaces.size > 0) refreshExclusionOverlay();
|
||||||
else setExclusionOverlay(null);
|
else setExclusionOverlay(null);
|
||||||
|
|
||||||
|
|||||||
+84
-7
@@ -202,12 +202,17 @@ const vertexShader = /* glsl */`
|
|||||||
attribute vec3 smoothNormal;
|
attribute vec3 smoothNormal;
|
||||||
attribute vec3 faceNormal;
|
attribute vec3 faceNormal;
|
||||||
attribute float faceMask;
|
attribute float faceMask;
|
||||||
|
attribute float boundaryFalloffAttr;
|
||||||
|
attribute float boundaryMaskTypeAttr;
|
||||||
|
|
||||||
varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment
|
varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment
|
||||||
varying vec3 vModelNormal; // model-space face normal → stable UV blending
|
varying vec3 vModelNormal; // model-space face normal → stable UV blending
|
||||||
varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular
|
varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular
|
||||||
varying vec3 vNormal; // view-space normal → lighting
|
varying vec3 vNormal; // view-space normal → lighting
|
||||||
varying float vFaceMask; // combined mask (angle + user exclusion)
|
varying vec3 vSmoothNormal; // view-space smooth normal → smooth shading on masked faces
|
||||||
|
varying float vFaceMask; // combined mask (angle + user exclusion + boundary falloff)
|
||||||
|
varying float vUserMask; // raw user-exclusion mask (0 = user-excluded, 1 = included)
|
||||||
|
varying float vMaskType; // boundary mask type (0 = user mask, 1 = angle mask)
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0);
|
vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0);
|
||||||
@@ -223,8 +228,10 @@ const vertexShader = /* glsl */`
|
|||||||
angleMask = min(angleMask, surfaceAngle > bottomAngleLimit ? 1.0 : 0.0);
|
angleMask = min(angleMask, surfaceAngle > bottomAngleLimit ? 1.0 : 0.0);
|
||||||
if (fN.z >= 0.0 && topAngleLimit >= 1.0)
|
if (fN.z >= 0.0 && topAngleLimit >= 1.0)
|
||||||
angleMask = min(angleMask, surfaceAngle > topAngleLimit ? 1.0 : 0.0);
|
angleMask = min(angleMask, surfaceAngle > topAngleLimit ? 1.0 : 0.0);
|
||||||
float totalMask = angleMask * faceMask;
|
float totalMask = angleMask * faceMask * boundaryFalloffAttr;
|
||||||
vFaceMask = totalMask;
|
vFaceMask = totalMask;
|
||||||
|
vUserMask = faceMask;
|
||||||
|
vMaskType = boundaryMaskTypeAttr;
|
||||||
|
|
||||||
if (useDisplacement == 1) {
|
if (useDisplacement == 1) {
|
||||||
float h = computeHeightAtPoint(position, safeN, safeN);
|
float h = computeHeightAtPoint(position, safeN, safeN);
|
||||||
@@ -243,6 +250,8 @@ const vertexShader = /* glsl */`
|
|||||||
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
|
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
|
||||||
vViewPos = mvPos.xyz;
|
vViewPos = mvPos.xyz;
|
||||||
vNormal = normalize(normalMatrix * fN);
|
vNormal = normalize(normalMatrix * fN);
|
||||||
|
vec3 sN = length(smoothNormal) > 1e-6 ? normalize(smoothNormal) : safeN;
|
||||||
|
vSmoothNormal = normalize(normalMatrix * sN);
|
||||||
gl_Position = projectionMatrix * mvPos;
|
gl_Position = projectionMatrix * mvPos;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -251,11 +260,19 @@ const fragmentShader = /* glsl */`
|
|||||||
precision highp float;
|
precision highp float;
|
||||||
${sharedGLSL}
|
${sharedGLSL}
|
||||||
|
|
||||||
|
uniform sampler2D boundaryEdgeTex;
|
||||||
|
uniform int boundaryEdgeCount;
|
||||||
|
uniform float boundaryEdgeTexWidth;
|
||||||
|
uniform float boundaryFalloffDist;
|
||||||
|
|
||||||
varying vec3 vModelPos;
|
varying vec3 vModelPos;
|
||||||
varying vec3 vModelNormal;
|
varying vec3 vModelNormal;
|
||||||
varying vec3 vViewPos;
|
varying vec3 vViewPos;
|
||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vSmoothNormal;
|
||||||
varying float vFaceMask;
|
varying float vFaceMask;
|
||||||
|
varying float vUserMask;
|
||||||
|
varying float vMaskType;
|
||||||
|
|
||||||
// Fragment-only wrapper: compute face-stable projection normal via dFdx
|
// Fragment-only wrapper: compute face-stable projection normal via dFdx
|
||||||
// then delegate to the shared height function.
|
// then delegate to the shared height function.
|
||||||
@@ -282,6 +299,27 @@ const fragmentShader = /* glsl */`
|
|||||||
|
|
||||||
// ── Combined mask (angle + user exclusion) from vertex shader ────────
|
// ── Combined mask (angle + user exclusion) from vertex shader ────────
|
||||||
float maskBlend = vFaceMask;
|
float maskBlend = vFaceMask;
|
||||||
|
|
||||||
|
// Per-fragment boundary falloff for bump-only mode. On coarse meshes the
|
||||||
|
// vertex attribute cannot produce a gradient (too few vertices), so we
|
||||||
|
// compute the distance from each pixel to the nearest boundary edge.
|
||||||
|
if (useDisplacement == 0 && boundaryFalloffDist > 0.001 && boundaryEdgeCount > 0) {
|
||||||
|
float minDist = boundaryFalloffDist;
|
||||||
|
for (int i = 0; i < 64; i++) {
|
||||||
|
if (i >= boundaryEdgeCount) break;
|
||||||
|
float uA = (float(i * 2) + 0.5) / boundaryEdgeTexWidth;
|
||||||
|
float uB = (float(i * 2 + 1) + 0.5) / boundaryEdgeTexWidth;
|
||||||
|
vec3 ea = texture2D(boundaryEdgeTex, vec2(uA, 0.5)).xyz;
|
||||||
|
vec3 eb = texture2D(boundaryEdgeTex, vec2(uB, 0.5)).xyz;
|
||||||
|
vec3 ab = eb - ea;
|
||||||
|
float abLen2 = dot(ab, ab);
|
||||||
|
float t = clamp(dot(vModelPos - ea, ab) / max(abLen2, 1e-10), 0.0, 1.0);
|
||||||
|
float d = length(vModelPos - (ea + t * ab));
|
||||||
|
if (d < minDist) { minDist = d; if (d < 1e-4) break; }
|
||||||
|
}
|
||||||
|
maskBlend *= clamp(minDist / boundaryFalloffDist, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
h *= maskBlend;
|
h *= maskBlend;
|
||||||
dhx *= maskBlend;
|
dhx *= maskBlend;
|
||||||
dhy *= maskBlend;
|
dhy *= maskBlend;
|
||||||
@@ -306,8 +344,20 @@ const fragmentShader = /* glsl */`
|
|||||||
vec3 bumpVec = N - bumpStr * (dhx * T + dhy * B);
|
vec3 bumpVec = N - bumpStr * (dhx * T + dhy * B);
|
||||||
vec3 bumpN = length(bumpVec) > 1e-6 ? normalize(bumpVec) : N;
|
vec3 bumpN = length(bumpVec) > 1e-6 ? normalize(bumpVec) : N;
|
||||||
|
|
||||||
|
// On fully masked faces the bump derivatives are zero, so bumpN falls
|
||||||
|
// back to the flat face normal → faceted/static look. Blend toward
|
||||||
|
// the smooth interpolated normal so masked areas get smooth shading.
|
||||||
|
vec3 smoothN = normalize(vSmoothNormal) * (gl_FrontFacing ? 1.0 : -1.0);
|
||||||
|
bumpN = mix(smoothN, bumpN, maskBlend);
|
||||||
|
|
||||||
// ── Shading ───────────────────────────────────────────────────────────
|
// ── Shading ───────────────────────────────────────────────────────────
|
||||||
vec3 baseColor = mix(vec3(0.50, 0.50, 0.50), vec3(0.22, 0.68, 0.68), maskBlend);
|
// Compute lighting identically for ALL surfaces using the teal base so
|
||||||
|
// that specular highlights, diffuse response, and view-dependent shading
|
||||||
|
// are perfectly consistent everywhere. Mask tinting is applied AFTER
|
||||||
|
// lighting as a colour blend so masked areas keep the same glossy look.
|
||||||
|
vec3 tealBase = vec3(0.22, 0.68, 0.68);
|
||||||
|
vec3 userMaskColor = vec3(0.85, 0.40, 0.15);
|
||||||
|
vec3 angleMaskColor = vec3(0.45, 0.48, 0.50);
|
||||||
|
|
||||||
vec3 L1 = normalize(vec3( 0.5, 0.8, 1.0));
|
vec3 L1 = normalize(vec3( 0.5, 0.8, 1.0));
|
||||||
vec3 L2 = normalize(vec3(-0.5, -0.2, -0.6));
|
vec3 L2 = normalize(vec3(-0.5, -0.2, -0.6));
|
||||||
@@ -319,10 +369,23 @@ const fragmentShader = /* glsl */`
|
|||||||
vec3 H1 = normalize(L1 + V);
|
vec3 H1 = normalize(L1 + V);
|
||||||
float spec = pow(max(dot(bumpN, H1), 0.0), 64.0) * 0.60;
|
float spec = pow(max(dot(bumpN, H1), 0.0), 64.0) * 0.60;
|
||||||
|
|
||||||
vec3 color = baseColor * 0.55 // ambient
|
// Lit teal (identical for textured and masked surfaces)
|
||||||
+ baseColor * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 // key light
|
vec3 litTeal = tealBase * 0.55
|
||||||
+ baseColor * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 // warm fill
|
+ tealBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55
|
||||||
+ vec3(spec); // specular
|
+ tealBase * diff2 * vec3(0.80, 0.60, 0.50) * 0.15
|
||||||
|
+ vec3(spec);
|
||||||
|
|
||||||
|
// Mask tint: pick colour by mask type, compute same lighting with that base
|
||||||
|
float maskEffect = 1.0 - maskBlend; // 0 = fully textured, 1 = fully masked
|
||||||
|
float effectiveMaskType = mix(vMaskType, 0.0, step(0.5, 1.0 - vUserMask));
|
||||||
|
vec3 maskBase = mix(userMaskColor, angleMaskColor, effectiveMaskType);
|
||||||
|
vec3 litMask = maskBase * 0.55
|
||||||
|
+ maskBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55
|
||||||
|
+ maskBase * diff2 * vec3(0.80, 0.60, 0.50) * 0.15
|
||||||
|
+ vec3(spec);
|
||||||
|
|
||||||
|
// Blend: 100% mask colour at the boundary, fading to 0% at falloff distance
|
||||||
|
vec3 color = mix(litTeal, litMask, maskEffect);
|
||||||
|
|
||||||
gl_FragColor = vec4(color, 1.0);
|
gl_FragColor = vec4(color, 1.0);
|
||||||
}
|
}
|
||||||
@@ -371,6 +434,7 @@ export function updateMaterial(material, displacementTexture, settings) {
|
|||||||
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
||||||
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
||||||
u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1);
|
u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1);
|
||||||
|
u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||||
@@ -399,6 +463,10 @@ function buildUniforms(tex, settings) {
|
|||||||
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
||||||
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
||||||
textureAspect: { value: new THREE.Vector2(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1) },
|
textureAspect: { value: new THREE.Vector2(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1) },
|
||||||
|
boundaryEdgeTex: { value: createFallbackDataTexture() },
|
||||||
|
boundaryEdgeCount: { value: 0 },
|
||||||
|
boundaryEdgeTexWidth: { value: 1.0 },
|
||||||
|
boundaryFalloffDist: { value: settings.boundaryFalloff ?? 0.0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,3 +480,12 @@ function createFallbackTexture() {
|
|||||||
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFallbackDataTexture() {
|
||||||
|
const data = new Float32Array(4);
|
||||||
|
const t = new THREE.DataTexture(data, 1, 1, THREE.RGBAFormat, THREE.FloatType);
|
||||||
|
t.minFilter = THREE.NearestFilter;
|
||||||
|
t.magFilter = THREE.NearestFilter;
|
||||||
|
t.needsUpdate = true;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,31 +47,31 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn {
|
.lang-dropdown {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 24px 0 10px; /* Add right padding for default select arrow if present */
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
appearance: none; /* Attempt to remove default styling */
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath fill='%2366667a' d='M0 0l4 4 4-4H0z'/%3E%3C/svg%3E"); /* Custom arrow */
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn:not(:last-child) {
|
.lang-dropdown:hover {
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-btn:hover {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn.active {
|
.lang-dropdown:focus {
|
||||||
background: var(--accent);
|
outline: none;
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Theme toggle button ─────────────────────────────────────────────── */
|
/* ── Theme toggle button ─────────────────────────────────────────────── */
|
||||||
@@ -391,6 +391,15 @@ main {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-subsection-heading {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Preset grid ─────────────────────────────────────────────────────── */
|
/* ── Preset grid ─────────────────────────────────────────────────────── */
|
||||||
.preset-grid {
|
.preset-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user