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;
|
||||
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>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<script type="importmap">
|
||||
@@ -46,8 +35,6 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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>
|
||||
<button id="theme-toggle" class="theme-toggle"
|
||||
data-i18n-title="theme.toggleTitle"
|
||||
@@ -237,9 +224,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mask Angles -->
|
||||
<!-- Masking -->
|
||||
<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">
|
||||
<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" />
|
||||
@@ -250,11 +247,9 @@
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Surface Exclusions -->
|
||||
<section class="panel-section">
|
||||
<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>
|
||||
<!-- By Surface -->
|
||||
<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>
|
||||
<p id="excl-hint" class="excl-hint" style="display:none"></p>
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
|
||||
// ── 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 ──────────
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -271,7 +384,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
||||
const mfTotal = maskedFracTotal[vid];
|
||||
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
|
||||
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 newY = tmpPos.y + smoothNrmY[vid] * disp;
|
||||
|
||||
+808
-10
@@ -3,6 +3,7 @@
|
||||
|
||||
export const TRANSLATIONS = {
|
||||
en: {
|
||||
'lang.name': 'English',
|
||||
// Theme toggle
|
||||
'theme.dark': 'Dark Theme',
|
||||
'theme.light': 'Light Theme',
|
||||
@@ -60,8 +61,11 @@ export const TRANSLATIONS = {
|
||||
'labels.capAngle': 'Cap Angle \u24d8',
|
||||
'tooltips.capAngle': 'Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'Masking',
|
||||
|
||||
// Mask angles section
|
||||
'sections.maskAngles': 'Mask Angles \u24d8',
|
||||
'sections.maskAngles': 'By Angle \u24d8',
|
||||
'tooltips.maskAngles': '0° = no masking. Surfaces within this angle of horizontal will not be textured.',
|
||||
'labels.bottomFaces': 'Bottom faces',
|
||||
'tooltips.bottomFaces': 'Suppress texture on downward-facing surfaces within this angle of horizontal',
|
||||
@@ -69,7 +73,7 @@ export const TRANSLATIONS = {
|
||||
'tooltips.topFaces': 'Suppress texture on upward-facing surfaces within this angle of horizontal',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'Surface Masking \u24d8',
|
||||
'sections.surfaceMasking': 'By Surface \u24d8',
|
||||
'sections.surfaceSelection': 'Surface Selection',
|
||||
'tooltips.surfaceMasking': 'Mask surfaces to control which areas receive displacement.',
|
||||
'tooltips.surfaceSelection': 'Selected surfaces appear green and will be the only ones to receive displacement during export.',
|
||||
@@ -81,7 +85,7 @@ export const TRANSLATIONS = {
|
||||
'excl.toolBrushTitle': 'Brush: paint triangles to exclude',
|
||||
'excl.toolFill': 'Fill',
|
||||
'excl.toolFillTitle': 'Bucket fill: flood-fill surface up to a threshold angle',
|
||||
'excl.shiftHint': 'Shift: erase \u00b7 Ctrl+click: straight line',
|
||||
'excl.shiftHint': 'Hold Shift to erase',
|
||||
'labels.type': 'Type',
|
||||
'brushType.single': 'Single',
|
||||
'brushType.circle': 'Circle',
|
||||
@@ -106,6 +110,10 @@ export const TRANSLATIONS = {
|
||||
'precision.refining': 'Refining\u2026',
|
||||
'precision.warningBody': 'Estimated ~{n} triangles. This may slow down your browser. Continue?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'Smooth Mask \u24d8',
|
||||
'tooltips.boundaryFalloff': 'Gradually reduces displacement to zero near masked boundaries, preventing triangle overlap where textured and non-textured regions meet.',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.',
|
||||
@@ -189,6 +197,7 @@ export const TRANSLATIONS = {
|
||||
},
|
||||
|
||||
de: {
|
||||
'lang.name': 'Deutsch',
|
||||
// Theme toggle
|
||||
'theme.dark': 'Dunkles Design',
|
||||
'theme.light': 'Helles Design',
|
||||
@@ -246,8 +255,11 @@ export const TRANSLATIONS = {
|
||||
'labels.capAngle': 'Übergangswinkel \u24d8',
|
||||
'tooltips.capAngle': 'Winkel (in Grad) ab dem die Deckel-/Bodenprojektion einsetzt. Kleinere Werte beschränken die Deckelprojektion auf nahezu flache Flächen.',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'Maskierung',
|
||||
|
||||
// Winkelmaskierung
|
||||
'sections.maskAngles': 'Winkel maskieren \u24d8',
|
||||
'sections.maskAngles': 'Nach Winkel \u24d8',
|
||||
'tooltips.maskAngles': '0° = keine Maskierung. Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen werden nicht texturiert.',
|
||||
'labels.bottomFaces': 'Unterseiten',
|
||||
'tooltips.bottomFaces': 'Textur auf nach unten gerichteten Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen unterdr\u00fccken',
|
||||
@@ -255,8 +267,8 @@ export const TRANSLATIONS = {
|
||||
'tooltips.topFaces': 'Textur auf nach oben gerichteten Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen unterdr\u00fccken',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'Fl\u00e4chenmaskierung \u24d8',
|
||||
'sections.surfaceSelection': 'Fl\u00e4chenauswahl',
|
||||
'sections.surfaceMasking': 'Nach Fläche \u24d8',
|
||||
'sections.surfaceSelection': 'Flächenauswahl',
|
||||
'tooltips.surfaceMasking': 'Fl\u00e4chen maskieren, um zu steuern, welche Bereiche Verschiebung erhalten.',
|
||||
'tooltips.surfaceSelection': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.',
|
||||
'excl.modeExclude': 'Ausschlie\u00dfen',
|
||||
@@ -267,7 +279,7 @@ export const TRANSLATIONS = {
|
||||
'excl.toolBrushTitle': 'Pinsel: Dreiecke zum Ausschlie\u00dfen einf\u00e4rben',
|
||||
'excl.toolFill': 'F\u00fcllen',
|
||||
'excl.toolFillTitle': 'F\u00fcllen: Fl\u00e4che bis zu einem Winkel fluten',
|
||||
'excl.shiftHint': 'Shift: Radieren \u00b7 Strg+Klick: Gerade Linie',
|
||||
'excl.shiftHint': 'Shift gedr\u00fcckt halten zum Radieren',
|
||||
'labels.type': 'Typ',
|
||||
'brushType.single': 'Einzeln',
|
||||
'brushType.circle': 'Kreis',
|
||||
@@ -292,6 +304,10 @@ export const TRANSLATIONS = {
|
||||
'precision.refining': 'Wird verfeinert\u2026',
|
||||
'precision.warningBody': 'Gesch\u00e4tzt ~{n} Dreiecke. Dies kann den Browser verlangsamen. Fortfahren?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'Maske glätten \u24d8',
|
||||
'tooltips.boundaryFalloff': 'Reduziert die Verschiebung schrittweise auf Null nahe maskierter Grenzen, um Dreiecks\u00fcberschneidungen an \u00dcberg\u00e4ngen zu vermeiden.',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': 'Symmetrische Verschiebung \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'Wenn aktiv: 50% Grau = keine Verschiebung; Weiß nach außen, Schwarz nach innen. H\u00e4lt das Volumen des Teils in etwa konstant.',
|
||||
@@ -373,6 +389,783 @@ export const TRANSLATIONS = {
|
||||
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
|
||||
'alerts.fileTooLarge': 'Datei zu gross ({size} MB). Maximum: {max} MB.',
|
||||
},
|
||||
|
||||
it: {
|
||||
'lang.name': 'Italiano',
|
||||
// Theme toggle
|
||||
'theme.dark': 'Tema Scuro',
|
||||
'theme.light': 'Tema Chiaro',
|
||||
'theme.toggleTitle': 'Attiva/disattiva modalità chiara/scura',
|
||||
'theme.toggleAriaLabel': 'Attiva/disattiva modalità chiara/scura',
|
||||
|
||||
// Drop zone
|
||||
'dropHint.text': 'Trascina qui un file <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for="stl-file-input" class="link-label">clicca per sfogliare</label>',
|
||||
|
||||
// Viewport footer
|
||||
'ui.wireframe': 'Wireframe',
|
||||
'ui.controlsHint': 'Trascina a sx: orbita \u00a0·\u00a0 Trascina a dx: sposta \u00a0·\u00a0 Scorri: zoom',
|
||||
'ui.meshInfo': '{n} triangoli · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||
|
||||
// Load model button
|
||||
'ui.loadStl': 'Carica Modello\u2026',
|
||||
|
||||
// Displacement map section
|
||||
'sections.displacementMap': 'Mappa di Deformazione',
|
||||
'ui.uploadCustomMap': 'Carica mappa personalizzata',
|
||||
'ui.noMapSelected': 'Nessuna mappa selezionata',
|
||||
|
||||
// Projection section
|
||||
'sections.projection': 'Proiezione',
|
||||
'labels.mode': 'Modalità',
|
||||
'projection.triplanar': 'Triplanare',
|
||||
'projection.cubic': 'Cubica (Box)',
|
||||
'projection.cylindrical': 'Cilindrica',
|
||||
'projection.spherical': 'Sferica',
|
||||
'projection.planarXY': 'Planare XY',
|
||||
'projection.planarXZ': 'Planare XZ',
|
||||
'projection.planarYZ': 'Planare YZ',
|
||||
|
||||
// Transform section
|
||||
'sections.transform': 'Trasformazioni',
|
||||
'labels.scaleU': 'Scala U',
|
||||
'labels.scaleV': 'Scala V',
|
||||
'labels.offsetU': 'Offset U',
|
||||
'labels.offsetV': 'Offset V',
|
||||
'labels.rotation': 'Rotazione',
|
||||
'tooltips.proportionalScaling': 'Scala proporzionale (U = V)',
|
||||
'tooltips.proportionalScalingAria': 'Scala proporzionale (U = V)',
|
||||
|
||||
// Displacement section
|
||||
'sections.displacement': 'Profondità Texture',
|
||||
'labels.amplitude': 'Ampiezza',
|
||||
|
||||
// Seam blend
|
||||
'labels.seamBlend': 'Unione dei bordi \u24d8',
|
||||
'tooltips.seamBlend': 'Attenua il bordo netto dove si incontrano le facce della proiezione. Efficace per le modalità Cubica e Cilindrica.',
|
||||
'labels.transitionSmoothing': 'Smoothing di transizione \u24d8',
|
||||
'tooltips.transitionSmoothing': 'Larghezza della zona di fusione vicino ai bordi della giuntura. Valori più bassi mantengono le transizioni aderenti alla giuntura; valori più alti sfumano una fascia più ampia.',
|
||||
'labels.textureSmoothing': 'Smoothing della texture \u24d8',
|
||||
'tooltips.textureSmoothing': 'Applica una sfocatura gaussiana alla mappa di deformazione. Valori più alti producono dettagli superficiali più morbidi e graduali. 0 = disattivato.',
|
||||
'labels.capAngle': 'Angolo di copertura \u24d8',
|
||||
'tooltips.capAngle': 'Angolo (in gradi) rispetto alla verticale al quale entra in gioco la proiezione della copertura superiore/inferiore. Valori più piccoli limitano la proiezione della copertura a facce quasi piatte.',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'Mascheramento',
|
||||
|
||||
// Mask angles section
|
||||
'sections.maskAngles': 'Per angolo \u24d8',
|
||||
'tooltips.maskAngles': '0° = nessuna mascheratura. Le superfici comprese in questo angolo rispetto all\'orizzontale non saranno texturizzate.',
|
||||
'labels.bottomFaces': 'Facce inferiori',
|
||||
'tooltips.bottomFaces': 'Elimina la texture sulle superfici rivolte verso il basso entro questo angolo rispetto all\'orizzontale',
|
||||
'labels.topFaces': 'Facce superiori',
|
||||
'tooltips.topFaces': 'Elimina la texture sulle superfici rivolte verso l\'alto entro questo angolo rispetto all\'orizzontale',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'Per superficie \u24d8',
|
||||
'sections.surfaceSelection': 'Selezione delle superfici',
|
||||
'tooltips.surfaceMasking': 'Mascherare le superfici per controllare quali aree subiscono la deformazione.',
|
||||
'tooltips.surfaceSelection': 'Le superfici selezionate appaiono in verde e saranno le uniche a subire la deformazione durante l\'esportazione.',
|
||||
'excl.modeExclude': 'Escludi',
|
||||
'excl.modeExcludeTitle': 'Modalità Escludi: le superfici dipinte non subiranno la deformazione della texture',
|
||||
'excl.modeIncludeOnly': 'Includi solo',
|
||||
'excl.modeIncludeOnlyTitle': 'Modalità Includi solo: solo le superfici dipinte subiranno la deformazione della texture',
|
||||
'excl.toolBrush': 'Pennello',
|
||||
'excl.toolBrushTitle': 'Pennello: dipingi i triangoli da escludere',
|
||||
'excl.toolFill': 'Riempimento',
|
||||
'excl.toolFillTitle': 'Riempimento a secchiello: riempi la superficie fino a un angolo di soglia',
|
||||
'excl.shiftHint': 'Tieni premuto Shift per cancellare',
|
||||
'labels.type': 'Tipo',
|
||||
'brushType.single': 'Singolo',
|
||||
'brushType.circle': 'Cerchio',
|
||||
'labels.size': 'Dimensione',
|
||||
'labels.maxAngle': 'Angolo massimo',
|
||||
'tooltips.maxAngle': 'Angolo diedro massimo tra triangoli adiacenti che il riempimento può attraversare',
|
||||
'ui.clearAll': 'Cancella tutto',
|
||||
'excl.initExcluded': '0 facce mascherate',
|
||||
'excl.faceExcluded': '{n} facce mascherate',
|
||||
'excl.facesExcluded': '{n} facce mascherate',
|
||||
'excl.faceSelected': '{n} faccia selezionata',
|
||||
'excl.facesSelected': '{n} facce selezionate',
|
||||
'excl.hintExclude': 'Le superfici mascherate appaiono in arancione e non riceveranno deformazione durante l\'esportazione',
|
||||
'excl.hintInclude': 'Le superfici selezionate appaiono verdi e saranno le uniche a ricevere la deformazione durante l\'esportazione.',
|
||||
|
||||
// Precision masking
|
||||
'precision.label': 'Precisione (Beta) \u24d8',
|
||||
'precision.labelTitle': 'Suddividi la mesh in background in modo che il pennello selezioni con una granularità più fine',
|
||||
'precision.outdated': '\u26a0 Obsoleto',
|
||||
'precision.refreshTitle': 'Risuddividi la mesh per adattarla alle dimensioni attuali del pennello',
|
||||
'precision.triCount': '{n} \u25b3',
|
||||
'precision.refining': 'Raffinamento\u2026',
|
||||
'precision.warningBody': 'Stima ~{n} triangoli. Ciò potrebbe rallentare il browser. Continuare?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'Maschera liscia \u24d8',
|
||||
'tooltips.boundaryFalloff': 'Riduce gradualmente la deformazione a zero vicino ai bordi mascherati, impedendo sovrapposizioni di triangoli tra zone con e senza texture.',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': 'Deformazione simmetrica \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'Quando è attivo, il grigio al 50% = nessuna deformazione; il bianco spinge verso l\'esterno, il nero spinge verso l\'interno. Mantiene il volume della parte approssimativamente costante.',
|
||||
|
||||
// Displacement preview
|
||||
'labels.displacementPreview': 'Anteprima 3D \u24d8',
|
||||
'tooltips.displacementPreview': 'Suddivide la mesh e sposta i vertici in tempo reale in modo da poter valutare la profondità effettiva. Richiede un uso intensivo della GPU su modelli complessi.',
|
||||
|
||||
// Place on face
|
||||
'ui.placeOnFace': 'Posiziona su una faccia',
|
||||
'ui.placeOnFaceTitle': 'Clicca su una faccia per orientarla verso il basso sul piano di stampa',
|
||||
'progress.subdividingPreview': 'Preparazione dell\'anteprima...',
|
||||
|
||||
// Amplitude overlap warning
|
||||
'warnings.amplitudeOverlap': '\u26a0 L\'ampiezza supera il 10% della dimensione più piccola del modello \u2014 potrebbero verificarsi sovrapposizioni geometriche nel file STL esportato.',
|
||||
|
||||
|
||||
// Export section
|
||||
'sections.export': 'Esporta \u24d8',
|
||||
'tooltips.export': 'Lunghezza del bordo più piccola = dettagli della deformazione più precisi. L\'output viene quindi ridotto al limite di triangoli.',
|
||||
'labels.resolution': 'Risoluzione',
|
||||
'tooltips.resolution': 'I bordi più lunghi di questo valore verranno suddivisi durante l\'esportazione',
|
||||
'labels.outputTriangles': 'Triangoli in uscita',
|
||||
'tooltips.outputTriangles': 'La mesh viene prima suddivisa completamente, poi decimata fino a questo numero',
|
||||
'warnings.safetyCapHit': '\u26a0 Limite di sicurezza di 20 milioni di triangoli raggiunto durante la suddivisione \u2014 il risultato potrebbe comunque essere più grossolano della lunghezza del bordo richiesta.',
|
||||
'ui.exportStl': 'Esporta STL',
|
||||
|
||||
// Export progress stages
|
||||
'progress.subdividing': 'Suddivisione della mesh\u2026',
|
||||
'progress.refining': 'Raffinamento: {cur} triangoli, spigolo più lungo {edge}',
|
||||
'progress.applyingDisplacement': 'Applicazione dello spostamento a {n} triangoli\u2026',
|
||||
'progress.displacingVertices': 'Spostamento dei vertici\u2026',
|
||||
'progress.decimatingTo': 'Semplificazione da {from} \u2192 {to} triangoli\u2026',
|
||||
'progress.decimating': 'Semplificazione: {cur} \u2192 {to} triangoli',
|
||||
'progress.writingStl': 'Scrittura STL\u2026',
|
||||
'progress.done': 'Fatto!',
|
||||
'progress.processing': 'Elaborazione\u2026',
|
||||
|
||||
// License popup
|
||||
'license.btn': 'Licenza e condizioni',
|
||||
'license.title': 'Licenza e condizioni',
|
||||
'license.item1': 'Utilizzo gratuito per qualsiasi scopo, compresi <strong>lavori commerciali</strong> (ad es. la creazione di texture per file STL destinati a clienti o prodotti).',
|
||||
'license.item2': 'L\'attribuzione è <strong>gradita</strong> ma <strong>non richiesta</strong> quando si utilizza questo strumento così com\'è.',
|
||||
'license.item3': 'Vuoi sostenere questo strumento? Acquista su <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> o fai una donazione su <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
|
||||
'license.item4': 'Questo strumento viene fornito <strong>così com\'è</strong> senza <strong>alcuna garanzia</strong> di alcun tipo. L\'utilizzo è a proprio rischio.',
|
||||
'license.item5': '<strong>Non viene fornita alcuna assistenza</strong>. L\'autore non ha alcun obbligo di correggere bug, rispondere a domande o aggiornare questo strumento. Detto questo, segnalazioni di bug e richieste di funzionalità sono sempre ben accette all\'indirizzo <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
|
||||
'license.item6': 'L\'autore non potrà essere ritenuto <strong>responsabile</strong> per eventuali danni, perdita di dati o problemi derivanti dall\'uso di questo strumento.',
|
||||
'license.item7': 'Vuoi ottenere una licenza o incorporare questo strumento per la tua attività o il tuo sito web? Contattaci all\'indirizzo <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
|
||||
'license.item8': 'Codice sorgente disponibile su <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
|
||||
|
||||
// Imprint & Privacy
|
||||
'imprint.btn': 'Note legali e privacy',
|
||||
'imprint.title': 'Note legali e informativa sulla privacy',
|
||||
'imprint.sectionImprint': 'Note legali (Impressum)',
|
||||
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Germania',
|
||||
'imprint.contact': 'E-mail: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Telefono: +49 175 2011824<br><em>Il numero di telefono è riservato esclusivamente a richieste legali/commerciali \u2014 non per l\'assistenza. </em>',
|
||||
'imprint.odr': 'Piattaforma UE per la risoluzione delle controversie online: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
|
||||
'imprint.sectionPrivacy': 'Informativa sulla privacy (Datenschutzerklärung)',
|
||||
'imprint.privacyIntro': '<strong>Titolare del trattamento</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Germania.',
|
||||
'imprint.privacyHosting': 'Questo sito web è ospitato su <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA). Quando visiti questo sito, GitHub potrebbe elaborare il tuo indirizzo IP nei log del server. Base giuridica: Art. 6(1)(f) GDPR (interesse legittimo alla fornitura del sito web). Vedi <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Informativa sulla privacy di GitHub</a>.',
|
||||
'imprint.privacyLocal': 'Questo strumento memorizza le preferenze dell\'utente (lingua, tema) nel <strong>localStorage</strong> del tuo browser. Questi dati non lasciano mai il tuo dispositivo e non vengono trasmessi a nessun server.',
|
||||
'imprint.privacyNoCookies': 'Questo sito web <strong>non</strong> utilizza cookie, strumenti di analisi o tecnologie di tracciamento.',
|
||||
'imprint.privacyExternal': 'Questo sito contiene link a siti web esterni (ad es. CNCKitchen.STORE, PayPal). Questi siti hanno le proprie politiche sulla privacy, sulle quali non abbiamo alcun controllo.',
|
||||
'imprint.privacyRights': 'Ai sensi del GDPR hai il diritto di <strong>accesso, rettifica, cancellazione, limitazione del trattamento, portabilità dei dati</strong> e il diritto di <strong>presentare un reclamo</strong> presso un\'autorità di controllo.',
|
||||
|
||||
// Sponsor modal
|
||||
'sponsor.title': 'Grazie per aver scelto BumpMesh di CNC Kitchen!',
|
||||
'sponsor.body': 'Questo strumento è offerto <strong>completamente gratis</strong> da CNC Kitchen.<br>Mentre il tuo file STL viene elaborato, perché non dai un\'occhiata al negozio che ci aiuta a continuare a creare cose fantastiche per te?',
|
||||
'sponsor.visitStore': '\uD83D\uDED2 Visita CNCKitchen.STORE',
|
||||
'sponsor.donate': '\uD83D\uDC99 Dona su PayPal',
|
||||
'sponsor.dontShow': 'Non mostrare più questo messaggio',
|
||||
'sponsor.closeAndContinue': 'Chiudi e continua',
|
||||
|
||||
// Store CTA
|
||||
'cta.store': 'Vuoi sostenere questo strumento? Acquista su <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> o dona su <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
|
||||
'cta.storeDismiss': 'Chiudi',
|
||||
|
||||
// Alerts
|
||||
'alerts.loadFailed': 'Caricamento del modello fallito: {msg}',
|
||||
'alerts.exportFailed': 'Esportazione fallita: {msg}',
|
||||
'alerts.fileTooLarge': 'File troppo grande ({size} MB). Massimo: {max} MB.',
|
||||
},
|
||||
|
||||
es: {
|
||||
'lang.name': 'Español (beta)',
|
||||
// Theme toggle
|
||||
'theme.dark': 'Tema Oscuro',
|
||||
'theme.light': 'Tema Claro',
|
||||
'theme.toggleTitle': 'Alternar modo claro / oscuro',
|
||||
'theme.toggleAriaLabel': 'Alternar modo claro/oscuro',
|
||||
|
||||
// Drop zone
|
||||
'dropHint.text': 'Arrastra aquí un archivo <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for="stl-file-input" class="link-label">haz clic para explorar</label>',
|
||||
|
||||
// Viewport footer
|
||||
'ui.wireframe': 'Malla de alambre',
|
||||
'ui.controlsHint': 'Arrastrar izq.: orbitar \u00a0·\u00a0 Arrastrar der.: desplazar \u00a0·\u00a0 Rueda: zoom',
|
||||
'ui.meshInfo': '{n} triángulos · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||
|
||||
// Load model button
|
||||
'ui.loadStl': 'Cargar modelo\u2026',
|
||||
|
||||
// Displacement map section
|
||||
'sections.displacementMap': 'Mapa de desplazamiento',
|
||||
'ui.uploadCustomMap': 'Subir mapa personalizado',
|
||||
'ui.noMapSelected': 'Ningún mapa seleccionado',
|
||||
|
||||
// Projection section
|
||||
'sections.projection': 'Proyección',
|
||||
'labels.mode': 'Modo',
|
||||
'projection.triplanar': 'Triplanar',
|
||||
'projection.cubic': 'Cúbica (Caja)',
|
||||
'projection.cylindrical': 'Cilíndrica',
|
||||
'projection.spherical': 'Esférica',
|
||||
'projection.planarXY': 'Planar XY',
|
||||
'projection.planarXZ': 'Planar XZ',
|
||||
'projection.planarYZ': 'Planar YZ',
|
||||
|
||||
// Transform section
|
||||
'sections.transform': 'Transformación',
|
||||
'labels.scaleU': 'Escala U',
|
||||
'labels.scaleV': 'Escala V',
|
||||
'labels.offsetU': 'Desplazamiento U',
|
||||
'labels.offsetV': 'Desplazamiento V',
|
||||
'labels.rotation': 'Rotación',
|
||||
'tooltips.proportionalScaling': 'Escalado proporcional (U = V)',
|
||||
'tooltips.proportionalScalingAria': 'Escalado proporcional (U = V)',
|
||||
|
||||
// Displacement section
|
||||
'sections.displacement': 'Profundidad de textura',
|
||||
'labels.amplitude': 'Amplitud',
|
||||
|
||||
// Seam blend
|
||||
'labels.seamBlend': 'Fusión de costuras \u24d8',
|
||||
'tooltips.seamBlend': 'Suaviza la costura donde se unen las caras de proyección. Efectivo para los modos Cúbico y Cilíndrico.',
|
||||
'labels.transitionSmoothing': 'Suavizado de transición \u24d8',
|
||||
'tooltips.transitionSmoothing': 'Ancho de la zona de fusión cerca de los bordes de la costura. Valores bajos mantienen las transiciones pegadas a la costura; valores altos difuminan una banda más amplia.',
|
||||
'labels.textureSmoothing': 'Suavizado de textura \u24d8',
|
||||
'tooltips.textureSmoothing': 'Aplica un desenfoque gaussiano al mapa de desplazamiento. Valores más altos producen detalles de superficie más suaves y graduales. 0 = desactivado.',
|
||||
'labels.capAngle': 'Ángulo de tapa \u24d8',
|
||||
'tooltips.capAngle': 'Ángulo (en grados) desde la vertical en el que se activa la proyección de tapa superior/inferior. Valores más pequeños limitan la proyección a caras casi planas.',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'Enmascaramiento',
|
||||
|
||||
// Mask angles section
|
||||
'sections.maskAngles': 'Por ángulo \u24d8',
|
||||
'tooltips.maskAngles': '0° = sin enmascaramiento. Las superficies dentro de este ángulo respecto a la horizontal no serán texturizadas.',
|
||||
'labels.bottomFaces': 'Caras inferiores',
|
||||
'tooltips.bottomFaces': 'Eliminar textura en superficies orientadas hacia abajo dentro de este ángulo respecto a la horizontal',
|
||||
'labels.topFaces': 'Caras superiores',
|
||||
'tooltips.topFaces': 'Eliminar textura en superficies orientadas hacia arriba dentro de este ángulo respecto a la horizontal',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'Por superficie \u24d8',
|
||||
'sections.surfaceSelection': 'Selección de superficies',
|
||||
'tooltips.surfaceMasking': 'Enmascarar superficies para controlar qué áreas reciben desplazamiento.',
|
||||
'tooltips.surfaceSelection': 'Las superficies seleccionadas aparecen en verde y serán las únicas en recibir desplazamiento durante la exportación.',
|
||||
'excl.modeExclude': 'Excluir',
|
||||
'excl.modeExcludeTitle': 'Modo Excluir: las superficies pintadas no recibirán desplazamiento de textura',
|
||||
'excl.modeIncludeOnly': 'Solo incluir',
|
||||
'excl.modeIncludeOnlyTitle': 'Modo Solo incluir: solo las superficies pintadas recibirán desplazamiento de textura',
|
||||
'excl.toolBrush': 'Pincel',
|
||||
'excl.toolBrushTitle': 'Pincel: pintar triángulos para excluir',
|
||||
'excl.toolFill': 'Relleno',
|
||||
'excl.toolFillTitle': 'Relleno: rellenar superficie hasta un ángulo umbral',
|
||||
'excl.shiftHint': 'Mantén Shift para borrar',
|
||||
'labels.type': 'Tipo',
|
||||
'brushType.single': 'Individual',
|
||||
'brushType.circle': 'Círculo',
|
||||
'labels.size': 'Tamaño',
|
||||
'labels.maxAngle': 'Ángulo máx.',
|
||||
'tooltips.maxAngle': 'Ángulo diedro máximo entre triángulos adyacentes que el relleno puede cruzar',
|
||||
'ui.clearAll': 'Borrar todo',
|
||||
'excl.initExcluded': '0 caras enmascaradas',
|
||||
'excl.faceExcluded': '{n} cara enmascarada',
|
||||
'excl.facesExcluded': '{n} caras enmascaradas',
|
||||
'excl.faceSelected': '{n} cara seleccionada',
|
||||
'excl.facesSelected': '{n} caras seleccionadas',
|
||||
'excl.hintExclude': 'Las superficies enmascaradas aparecen en naranja y no recibirán desplazamiento durante la exportación.',
|
||||
'excl.hintInclude': 'Las superficies seleccionadas aparecen en verde y serán las únicas en recibir desplazamiento durante la exportación.',
|
||||
|
||||
// Precision masking
|
||||
'precision.label': 'Precisión (Beta) \u24d8',
|
||||
'precision.labelTitle': 'Subdivide la malla en segundo plano para que el pincel seleccione con mayor granularidad',
|
||||
'precision.outdated': '\u26a0 Desactualizado',
|
||||
'precision.refreshTitle': 'Resubdividir la malla para ajustarla al tamaño actual del pincel',
|
||||
'precision.triCount': '{n} \u25b3',
|
||||
'precision.refining': 'Refinando\u2026',
|
||||
'precision.warningBody': 'Estimados ~{n} triángulos. Esto puede ralentizar el navegador. ¿Continuar?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'Suavizar máscara \u24d8',
|
||||
'tooltips.boundaryFalloff': 'Reduce gradualmente el desplazamiento a cero cerca de los bordes enmascarados, evitando superposiciones de triángulos entre zonas texturizadas y no texturizadas.',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': 'Desplazamiento simétrico \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'Cuando está activado, el gris al 50% = sin desplazamiento; el blanco empuja hacia fuera, el negro empuja hacia dentro. Mantiene el volumen de la pieza aproximadamente constante.',
|
||||
|
||||
// Displacement preview
|
||||
'labels.displacementPreview': 'Vista previa 3D \u24d8',
|
||||
'tooltips.displacementPreview': 'Subdivide la malla y desplaza los vértices en tiempo real para evaluar la profundidad real. Uso intensivo de GPU en modelos complejos.',
|
||||
|
||||
// Place on face
|
||||
'ui.placeOnFace': 'Colocar en cara',
|
||||
'ui.placeOnFaceTitle': 'Haz clic en una cara para orientarla hacia abajo sobre la cama de impresión',
|
||||
'progress.subdividingPreview': 'Preparando vista previa\u2026',
|
||||
|
||||
// Amplitude overlap warning
|
||||
'warnings.amplitudeOverlap': '\u26a0 La amplitud supera el 10% de la dimensión más pequeña del modelo \u2014 pueden ocurrir superposiciones de geometría en el STL exportado.',
|
||||
|
||||
// Export section
|
||||
'sections.export': 'Exportar \u24d8',
|
||||
'tooltips.export': 'Menor longitud de arista = mayor detalle de desplazamiento. La salida se reduce al límite de triángulos.',
|
||||
'labels.resolution': 'Resolución',
|
||||
'tooltips.resolution': 'Las aristas más largas que este valor se subdividirán durante la exportación',
|
||||
'labels.outputTriangles': 'Triángulos de salida',
|
||||
'tooltips.outputTriangles': 'La malla se subdivide completamente primero y luego se reduce a esta cantidad',
|
||||
'warnings.safetyCapHit': '\u26a0 Se alcanzó el límite de seguridad de 20M de triángulos durante la subdivisión \u2014 el resultado puede ser más grueso que la longitud de arista solicitada.',
|
||||
'ui.exportStl': 'Exportar STL',
|
||||
|
||||
// Export progress stages
|
||||
'progress.subdividing': 'Subdividiendo malla\u2026',
|
||||
'progress.refining': 'Refinando: {cur} triángulos, arista más larga {edge}',
|
||||
'progress.applyingDisplacement': 'Aplicando desplazamiento a {n} triángulos\u2026',
|
||||
'progress.displacingVertices': 'Desplazando vértices\u2026',
|
||||
'progress.decimatingTo': 'Simplificando {from} \u2192 {to} triángulos\u2026',
|
||||
'progress.decimating': 'Simplificando: {cur} \u2192 {to} triángulos',
|
||||
'progress.writingStl': 'Escribiendo STL\u2026',
|
||||
'progress.done': '¡Listo!',
|
||||
'progress.processing': 'Procesando\u2026',
|
||||
|
||||
// License popup
|
||||
'license.btn': 'Licencia y condiciones',
|
||||
'license.title': 'Licencia y condiciones',
|
||||
'license.item1': 'Uso gratuito para cualquier propósito, incluyendo <strong>trabajo comercial</strong> (p. ej., texturizado de STLs para clientes o productos).',
|
||||
'license.item2': 'La atribución es <strong>apreciada</strong> pero <strong>no obligatoria</strong> al usar esta herramienta tal cual.',
|
||||
'license.item3': '¿Quieres apoyar esta herramienta? Compra en <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> o dona en <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
|
||||
'license.item4': 'Esta herramienta se proporciona <strong>tal cual</strong> sin <strong>ninguna garantía</strong> de ningún tipo. Úsala bajo tu propio riesgo.',
|
||||
'license.item5': '<strong>No se proporciona soporte</strong>. El autor no tiene obligación de corregir errores, responder preguntas ni actualizar esta herramienta. Dicho esto, los informes de errores y solicitudes de funciones son siempre bienvenidos en <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
|
||||
'license.item6': 'El autor no será responsable de <strong>daños</strong>, pérdida de datos o problemas derivados del uso de esta herramienta.',
|
||||
'license.item7': '¿Quieres licenciar o integrar esta herramienta para tu negocio o sitio web? Contáctanos en <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
|
||||
'license.item8': 'Código fuente disponible en <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
|
||||
|
||||
// Imprint & Privacy
|
||||
'imprint.btn': 'Aviso legal y privacidad',
|
||||
'imprint.title': 'Aviso legal y política de privacidad',
|
||||
'imprint.sectionImprint': 'Aviso legal (Impressum)',
|
||||
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Alemania',
|
||||
'imprint.contact': 'Correo: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Teléfono: +49 175 2011824<br><em>El número de teléfono es exclusivamente para consultas legales/comerciales \u2014 no para soporte.</em>',
|
||||
'imprint.odr': 'Plataforma de resolución de litigios en línea de la UE: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
|
||||
'imprint.sectionPrivacy': 'Política de privacidad (Datenschutzerklärung)',
|
||||
'imprint.privacyIntro': '<strong>Responsable</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Alemania.',
|
||||
'imprint.privacyHosting': 'Este sitio web está alojado en <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, EE.UU.). Al visitar este sitio, GitHub puede procesar tu dirección IP en los registros del servidor. Base legal: Art. 6(1)(f) RGPD (interés legítimo en proporcionar el sitio web). Ver <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Declaración de privacidad de GitHub</a>.',
|
||||
'imprint.privacyLocal': 'Esta herramienta almacena las preferencias del usuario (idioma, tema) en el <strong>localStorage</strong> de tu navegador. Estos datos nunca salen de tu dispositivo ni se transmiten a ningún servidor.',
|
||||
'imprint.privacyNoCookies': 'Este sitio web <strong>no</strong> utiliza cookies, herramientas de análisis ni tecnologías de rastreo.',
|
||||
'imprint.privacyExternal': 'Este sitio contiene enlaces a sitios web externos (p. ej., CNCKitchen.STORE, PayPal). Estos sitios tienen sus propias políticas de privacidad, sobre las cuales no tenemos control.',
|
||||
'imprint.privacyRights': 'Según el RGPD, tienes derecho a <strong>acceso, rectificación, supresión, limitación del tratamiento, portabilidad de datos</strong> y derecho a <strong>presentar una reclamación</strong> ante una autoridad de control.',
|
||||
|
||||
// Sponsor modal
|
||||
'sponsor.title': '¡Gracias por usar BumpMesh de CNC Kitchen!',
|
||||
'sponsor.body': 'Esta herramienta es proporcionada <strong>completamente gratis</strong> por CNC Kitchen.<br>Mientras se procesa tu STL, ¿por qué no echas un vistazo a la tienda que nos ayuda a seguir creando cosas geniales para ti?',
|
||||
'sponsor.visitStore': '\uD83D\uDED2 Visitar CNCKitchen.STORE',
|
||||
'sponsor.donate': '\uD83D\uDC99 Donar en PayPal',
|
||||
'sponsor.dontShow': 'No mostrar de nuevo',
|
||||
'sponsor.closeAndContinue': 'Cerrar y continuar',
|
||||
|
||||
// Store CTA
|
||||
'cta.store': '¿Quieres apoyar esta herramienta? Compra en <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> o dona en <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
|
||||
'cta.storeDismiss': 'Cerrar',
|
||||
|
||||
// Alerts
|
||||
'alerts.loadFailed': 'No se pudo cargar el modelo: {msg}',
|
||||
'alerts.exportFailed': 'Error en la exportación: {msg}',
|
||||
'alerts.fileTooLarge': 'Archivo demasiado grande ({size} MB). Máximo: {max} MB.',
|
||||
},
|
||||
|
||||
pt: {
|
||||
'lang.name': 'Português (beta)',
|
||||
// Theme toggle
|
||||
'theme.dark': 'Tema Escuro',
|
||||
'theme.light': 'Tema Claro',
|
||||
'theme.toggleTitle': 'Alternar modo claro / escuro',
|
||||
'theme.toggleAriaLabel': 'Alternar modo claro/escuro',
|
||||
|
||||
// Drop zone
|
||||
'dropHint.text': 'Arraste um arquivo <strong>.stl</strong>, <strong>.obj</strong> ou <strong>.3mf</strong> aqui<br/>ou <label for="stl-file-input" class="link-label">clique para procurar</label>',
|
||||
|
||||
// Viewport footer
|
||||
'ui.wireframe': 'Wireframe',
|
||||
'ui.controlsHint': 'Arrastar esq.: orbitar \u00a0·\u00a0 Arrastar dir.: deslocar \u00a0·\u00a0 Roda: zoom',
|
||||
'ui.meshInfo': '{n} triângulos · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||
|
||||
// Load model button
|
||||
'ui.loadStl': 'Carregar modelo\u2026',
|
||||
|
||||
// Displacement map section
|
||||
'sections.displacementMap': 'Mapa de deslocamento',
|
||||
'ui.uploadCustomMap': 'Enviar mapa personalizado',
|
||||
'ui.noMapSelected': 'Nenhum mapa selecionado',
|
||||
|
||||
// Projection section
|
||||
'sections.projection': 'Projeção',
|
||||
'labels.mode': 'Modo',
|
||||
'projection.triplanar': 'Triplanar',
|
||||
'projection.cubic': 'Cúbica (Caixa)',
|
||||
'projection.cylindrical': 'Cilíndrica',
|
||||
'projection.spherical': 'Esférica',
|
||||
'projection.planarXY': 'Planar XY',
|
||||
'projection.planarXZ': 'Planar XZ',
|
||||
'projection.planarYZ': 'Planar YZ',
|
||||
|
||||
// Transform section
|
||||
'sections.transform': 'Transformação',
|
||||
'labels.scaleU': 'Escala U',
|
||||
'labels.scaleV': 'Escala V',
|
||||
'labels.offsetU': 'Deslocamento U',
|
||||
'labels.offsetV': 'Deslocamento V',
|
||||
'labels.rotation': 'Rotação',
|
||||
'tooltips.proportionalScaling': 'Escala proporcional (U = V)',
|
||||
'tooltips.proportionalScalingAria': 'Escala proporcional (U = V)',
|
||||
|
||||
// Displacement section
|
||||
'sections.displacement': 'Profundidade da textura',
|
||||
'labels.amplitude': 'Amplitude',
|
||||
|
||||
// Seam blend
|
||||
'labels.seamBlend': 'Fusão de costuras \u24d8',
|
||||
'tooltips.seamBlend': 'Suaviza a costura onde as faces de projeção se encontram. Eficaz para os modos Cúbico e Cilíndrico.',
|
||||
'labels.transitionSmoothing': 'Suavização de transição \u24d8',
|
||||
'tooltips.transitionSmoothing': 'Largura da zona de fusão perto das bordas da costura. Valores baixos mantêm as transições próximas à costura; valores altos suavizam uma faixa mais larga.',
|
||||
'labels.textureSmoothing': 'Suavização de textura \u24d8',
|
||||
'tooltips.textureSmoothing': 'Aplica um desfoque gaussiano ao mapa de deslocamento. Valores mais altos produzem detalhes de superfície mais suaves e graduais. 0 = desativado.',
|
||||
'labels.capAngle': 'Ângulo de cobertura \u24d8',
|
||||
'tooltips.capAngle': 'Ângulo (em graus) a partir da vertical no qual a projeção de cobertura superior/inferior é ativada. Valores menores limitam a projeção a faces quase planas.',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'Mascaramento',
|
||||
|
||||
// Mask angles section
|
||||
'sections.maskAngles': 'Por ângulo \u24d8',
|
||||
'tooltips.maskAngles': '0° = sem mascaramento. Superfícies dentro deste ângulo em relação à horizontal não serão texturizadas.',
|
||||
'labels.bottomFaces': 'Faces inferiores',
|
||||
'tooltips.bottomFaces': 'Suprimir textura em superfícies voltadas para baixo dentro deste ângulo em relação à horizontal',
|
||||
'labels.topFaces': 'Faces superiores',
|
||||
'tooltips.topFaces': 'Suprimir textura em superfícies voltadas para cima dentro deste ângulo em relação à horizontal',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'Por superfície \u24d8',
|
||||
'sections.surfaceSelection': 'Seleção de superfícies',
|
||||
'tooltips.surfaceMasking': 'Mascarar superfícies para controlar quais áreas recebem deslocamento.',
|
||||
'tooltips.surfaceSelection': 'As superfícies selecionadas aparecem em verde e serão as únicas a receber deslocamento durante a exportação.',
|
||||
'excl.modeExclude': 'Excluir',
|
||||
'excl.modeExcludeTitle': 'Modo Excluir: superfícies pintadas não receberão deslocamento de textura',
|
||||
'excl.modeIncludeOnly': 'Incluir apenas',
|
||||
'excl.modeIncludeOnlyTitle': 'Modo Incluir apenas: somente superfícies pintadas receberão deslocamento de textura',
|
||||
'excl.toolBrush': 'Pincel',
|
||||
'excl.toolBrushTitle': 'Pincel: pintar triângulos para excluir',
|
||||
'excl.toolFill': 'Preenchimento',
|
||||
'excl.toolFillTitle': 'Preenchimento: preencher superfície até um ângulo limite',
|
||||
'excl.shiftHint': 'Segure Shift para apagar',
|
||||
'labels.type': 'Tipo',
|
||||
'brushType.single': 'Individual',
|
||||
'brushType.circle': 'Círculo',
|
||||
'labels.size': 'Tamanho',
|
||||
'labels.maxAngle': 'Ângulo máx.',
|
||||
'tooltips.maxAngle': 'Ângulo diedral máximo entre triângulos adjacentes que o preenchimento pode cruzar',
|
||||
'ui.clearAll': 'Limpar tudo',
|
||||
'excl.initExcluded': '0 faces mascaradas',
|
||||
'excl.faceExcluded': '{n} face mascarada',
|
||||
'excl.facesExcluded': '{n} faces mascaradas',
|
||||
'excl.faceSelected': '{n} face selecionada',
|
||||
'excl.facesSelected': '{n} faces selecionadas',
|
||||
'excl.hintExclude': 'Superfícies mascaradas aparecem em laranja e não receberão deslocamento durante a exportação.',
|
||||
'excl.hintInclude': 'Superfícies selecionadas aparecem em verde e serão as únicas a receber deslocamento durante a exportação.',
|
||||
|
||||
// Precision masking
|
||||
'precision.label': 'Precisão (Beta) \u24d8',
|
||||
'precision.labelTitle': 'Subdivide a malha em segundo plano para que o pincel selecione com maior granularidade',
|
||||
'precision.outdated': '\u26a0 Desatualizado',
|
||||
'precision.refreshTitle': 'Resubdividir a malha para ajustar ao tamanho atual do pincel',
|
||||
'precision.triCount': '{n} \u25b3',
|
||||
'precision.refining': 'Refinando\u2026',
|
||||
'precision.warningBody': 'Estimados ~{n} triângulos. Isso pode deixar o navegador lento. Continuar?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'Suavizar máscara \u24d8',
|
||||
'tooltips.boundaryFalloff': 'Reduz gradualmente o deslocamento a zero perto das bordas mascaradas, evitando sobreposição de triângulos entre zonas texturizadas e não texturizadas.',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': 'Deslocamento simétrico \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'Quando ativado, cinza a 50% = sem deslocamento; branco empurra para fora, preto empurra para dentro. Mantém o volume da peça aproximadamente constante.',
|
||||
|
||||
// Displacement preview
|
||||
'labels.displacementPreview': 'Pré-visualização 3D \u24d8',
|
||||
'tooltips.displacementPreview': 'Subdivide a malha e desloca os vértices em tempo real para avaliar a profundidade real. Uso intensivo de GPU em modelos complexos.',
|
||||
|
||||
// Place on face
|
||||
'ui.placeOnFace': 'Posicionar na face',
|
||||
'ui.placeOnFaceTitle': 'Clique numa face para orientá-la para baixo sobre a mesa de impressão',
|
||||
'progress.subdividingPreview': 'Preparando pré-visualização\u2026',
|
||||
|
||||
// Amplitude overlap warning
|
||||
'warnings.amplitudeOverlap': '\u26a0 A amplitude excede 10% da menor dimensão do modelo \u2014 podem ocorrer sobreposições de geometria no STL exportado.',
|
||||
|
||||
// Export section
|
||||
'sections.export': 'Exportar \u24d8',
|
||||
'tooltips.export': 'Menor comprimento de aresta = maior detalhe de deslocamento. A saída é então reduzida ao limite de triângulos.',
|
||||
'labels.resolution': 'Resolução',
|
||||
'tooltips.resolution': 'Arestas maiores que este valor serão subdivididas durante a exportação',
|
||||
'labels.outputTriangles': 'Triângulos de saída',
|
||||
'tooltips.outputTriangles': 'A malha é totalmente subdividida primeiro e depois reduzida a esta quantidade',
|
||||
'warnings.safetyCapHit': '\u26a0 Limite de segurança de 20M de triângulos atingido durante a subdivisão \u2014 o resultado pode ser mais grosseiro que o comprimento de aresta solicitado.',
|
||||
'ui.exportStl': 'Exportar STL',
|
||||
|
||||
// Export progress stages
|
||||
'progress.subdividing': 'Subdividindo malha\u2026',
|
||||
'progress.refining': 'Refinando: {cur} triângulos, aresta mais longa {edge}',
|
||||
'progress.applyingDisplacement': 'Aplicando deslocamento em {n} triângulos\u2026',
|
||||
'progress.displacingVertices': 'Deslocando vértices\u2026',
|
||||
'progress.decimatingTo': 'Simplificando {from} \u2192 {to} triângulos\u2026',
|
||||
'progress.decimating': 'Simplificando: {cur} \u2192 {to} triângulos',
|
||||
'progress.writingStl': 'Escrevendo STL\u2026',
|
||||
'progress.done': 'Pronto!',
|
||||
'progress.processing': 'Processando\u2026',
|
||||
|
||||
// License popup
|
||||
'license.btn': 'Licença e termos',
|
||||
'license.title': 'Licença e termos',
|
||||
'license.item1': 'Uso gratuito para qualquer finalidade, incluindo <strong>trabalho comercial</strong> (p. ex., texturização de STLs para clientes ou produtos).',
|
||||
'license.item2': 'A atribuição é <strong>apreciada</strong> mas <strong>não obrigatória</strong> ao usar esta ferramenta como está.',
|
||||
'license.item3': 'Quer apoiar esta ferramenta? Compre na <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a> ou doe via <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>.',
|
||||
'license.item4': 'Esta ferramenta é fornecida <strong>como está</strong> sem <strong>nenhuma garantia</strong> de qualquer tipo. Use por sua conta e risco.',
|
||||
'license.item5': '<strong>Nenhum suporte</strong> é fornecido. O autor não tem obrigação de corrigir bugs, responder perguntas ou atualizar esta ferramenta. Dito isso, relatórios de bugs e pedidos de funcionalidades são sempre bem-vindos em <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a>.',
|
||||
'license.item6': 'O autor não será responsável por <strong>danos</strong>, perda de dados ou problemas decorrentes do uso desta ferramenta.',
|
||||
'license.item7': 'Quer licenciar ou incorporar esta ferramenta para o seu negócio ou site? Entre em contato em <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a>.',
|
||||
'license.item8': 'Código-fonte disponível no <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a>.',
|
||||
|
||||
// Imprint & Privacy
|
||||
'imprint.btn': 'Aviso legal e privacidade',
|
||||
'imprint.title': 'Aviso legal e política de privacidade',
|
||||
'imprint.sectionImprint': 'Aviso legal (Impressum)',
|
||||
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>Alemanha',
|
||||
'imprint.contact': 'E-mail: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>Telefone: +49 175 2011824<br><em>O número de telefone é exclusivamente para consultas legais/comerciais \u2014 não para suporte.</em>',
|
||||
'imprint.odr': 'Plataforma de resolução de litígios online da UE: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
|
||||
'imprint.sectionPrivacy': 'Política de privacidade (Datenschutzerklärung)',
|
||||
'imprint.privacyIntro': '<strong>Responsável</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, Alemanha.',
|
||||
'imprint.privacyHosting': 'Este site é hospedado no <strong>GitHub Pages</strong> (GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, EUA). Ao visitar este site, o GitHub pode processar seu endereço IP nos logs do servidor. Base legal: Art. 6(1)(f) RGPD (interesse legítimo em fornecer o site). Veja a <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">Declaração de privacidade do GitHub</a>.',
|
||||
'imprint.privacyLocal': 'Esta ferramenta armazena as preferências do usuário (idioma, tema) no <strong>localStorage</strong> do seu navegador. Esses dados nunca saem do seu dispositivo e não são transmitidos a nenhum servidor.',
|
||||
'imprint.privacyNoCookies': 'Este site <strong>não</strong> utiliza cookies, ferramentas de análise ou tecnologias de rastreamento.',
|
||||
'imprint.privacyExternal': 'Este site contém links para sites externos (p. ex., CNCKitchen.STORE, PayPal). Esses sites têm suas próprias políticas de privacidade, sobre as quais não temos controle.',
|
||||
'imprint.privacyRights': 'Nos termos do RGPD, você tem direito a <strong>acesso, retificação, eliminação, limitação do tratamento, portabilidade de dados</strong> e direito de <strong>apresentar uma reclamação</strong> junto a uma autoridade de supervisão.',
|
||||
|
||||
// Sponsor modal
|
||||
'sponsor.title': 'Obrigado por usar o BumpMesh da CNC Kitchen!',
|
||||
'sponsor.body': 'Esta ferramenta é fornecida <strong>totalmente grátis</strong> pela CNC Kitchen.<br>Enquanto seu STL está sendo processado, que tal dar uma olhada na loja que nos ajuda a continuar criando coisas legais para você?',
|
||||
'sponsor.visitStore': '\uD83D\uDED2 Visitar CNCKitchen.STORE',
|
||||
'sponsor.donate': '\uD83D\uDC99 Doar via PayPal',
|
||||
'sponsor.dontShow': 'Não mostrar novamente',
|
||||
'sponsor.closeAndContinue': 'Fechar e continuar',
|
||||
|
||||
// Store CTA
|
||||
'cta.store': 'Quer apoiar esta ferramenta? Compre na <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> ou doe via <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>',
|
||||
'cta.storeDismiss': 'Fechar',
|
||||
|
||||
// Alerts
|
||||
'alerts.loadFailed': 'Não foi possível carregar o modelo: {msg}',
|
||||
'alerts.exportFailed': 'Falha na exportação: {msg}',
|
||||
'alerts.fileTooLarge': 'Arquivo muito grande ({size} MB). Máximo: {max} MB.',
|
||||
},
|
||||
|
||||
ja: {
|
||||
'lang.name': '日本語 (beta)',
|
||||
// Theme toggle
|
||||
'theme.dark': 'ダークテーマ',
|
||||
'theme.light': 'ライトテーマ',
|
||||
'theme.toggleTitle': 'ライト/ダークモードを切り替え',
|
||||
'theme.toggleAriaLabel': 'ライト/ダークモードを切り替え',
|
||||
|
||||
// Drop zone
|
||||
'dropHint.text': '<strong>.stl</strong>、<strong>.obj</strong>、<strong>.3mf</strong> ファイルをここにドロップ<br/>または <label for="stl-file-input" class="link-label">クリックして参照</label>',
|
||||
|
||||
// Viewport footer
|
||||
'ui.wireframe': 'ワイヤーフレーム',
|
||||
'ui.controlsHint': '左ドラッグ: 回転 \u00a0·\u00a0 右ドラッグ: パン \u00a0·\u00a0 スクロール: ズーム',
|
||||
'ui.meshInfo': '{n} 三角形 · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||
|
||||
// Load model button
|
||||
'ui.loadStl': 'モデルを読み込む\u2026',
|
||||
|
||||
// Displacement map section
|
||||
'sections.displacementMap': 'ディスプレイスメントマップ',
|
||||
'ui.uploadCustomMap': 'カスタムマップをアップロード',
|
||||
'ui.noMapSelected': 'マップが選択されていません',
|
||||
|
||||
// Projection section
|
||||
'sections.projection': '投影',
|
||||
'labels.mode': 'モード',
|
||||
'projection.triplanar': 'トライプラナー',
|
||||
'projection.cubic': 'キュービック (ボックス)',
|
||||
'projection.cylindrical': '円筒',
|
||||
'projection.spherical': '球面',
|
||||
'projection.planarXY': '平面 XY',
|
||||
'projection.planarXZ': '平面 XZ',
|
||||
'projection.planarYZ': '平面 YZ',
|
||||
|
||||
// Transform section
|
||||
'sections.transform': '変換',
|
||||
'labels.scaleU': 'スケール U',
|
||||
'labels.scaleV': 'スケール V',
|
||||
'labels.offsetU': 'オフセット U',
|
||||
'labels.offsetV': 'オフセット V',
|
||||
'labels.rotation': '回転',
|
||||
'tooltips.proportionalScaling': '比例スケーリング (U = V)',
|
||||
'tooltips.proportionalScalingAria': '比例スケーリング (U = V)',
|
||||
|
||||
// Displacement section
|
||||
'sections.displacement': 'テクスチャの深さ',
|
||||
'labels.amplitude': '振幅',
|
||||
|
||||
// Seam blend
|
||||
'labels.seamBlend': 'シームブレンド \u24d8',
|
||||
'tooltips.seamBlend': '投影面が接する境界の硬い継ぎ目を滑らかにします。キュービックおよび円筒モードで効果的です。',
|
||||
'labels.transitionSmoothing': 'トランジションスムージング \u24d8',
|
||||
'tooltips.transitionSmoothing': '継ぎ目の端付近のブレンドゾーンの幅。低い値はトランジションを継ぎ目に近づけ、高い値はより広い帯域をブレンドします。',
|
||||
'labels.textureSmoothing': 'テクスチャスムージング \u24d8',
|
||||
'tooltips.textureSmoothing': 'ディスプレイスメントマップにガウシアンブラーを適用します。値が高いほど、より滑らかで緩やかな表面ディテールになります。0 = オフ。',
|
||||
'labels.capAngle': 'キャップ角度 \u24d8',
|
||||
'tooltips.capAngle': '上面/下面のキャップ投影が作動する垂直からの角度(度数)。小さい値はキャップ投影をほぼ平らな面に制限します。',
|
||||
|
||||
// Masking parent section
|
||||
'sections.masking': 'マスキング',
|
||||
|
||||
// Mask angles section
|
||||
'sections.maskAngles': '角度別 \u24d8',
|
||||
'tooltips.maskAngles': '0° = マスクなし。水平からこの角度内の面はテクスチャが適用されません。',
|
||||
'labels.bottomFaces': '底面',
|
||||
'tooltips.bottomFaces': '水平からこの角度内の下向きの面のテクスチャを抑制',
|
||||
'labels.topFaces': '上面',
|
||||
'tooltips.topFaces': '水平からこの角度内の上向きの面のテクスチャを抑制',
|
||||
|
||||
// Surface masking section
|
||||
'sections.surfaceMasking': 'サーフェス別 \u24d8',
|
||||
'sections.surfaceSelection': 'サーフェス選択',
|
||||
'tooltips.surfaceMasking': 'サーフェスをマスクして、どの領域にディスプレイスメントを適用するかを制御します。',
|
||||
'tooltips.surfaceSelection': '選択されたサーフェスは緑色で表示され、エクスポート時にディスプレイスメントを受ける唯一の面になります。',
|
||||
'excl.modeExclude': '除外',
|
||||
'excl.modeExcludeTitle': '除外モード: 塗られたサーフェスはテクスチャディスプレイスメントを受けません',
|
||||
'excl.modeIncludeOnly': '選択のみ',
|
||||
'excl.modeIncludeOnlyTitle': '選択のみモード: 塗られたサーフェスのみがテクスチャディスプレイスメントを受けます',
|
||||
'excl.toolBrush': 'ブラシ',
|
||||
'excl.toolBrushTitle': 'ブラシ: 三角形を塗って除外',
|
||||
'excl.toolFill': '塗りつぶし',
|
||||
'excl.toolFillTitle': '塗りつぶし: 閾値角度までサーフェスをフラッドフィル',
|
||||
'excl.shiftHint': 'Shiftキーを押しながら消去',
|
||||
'labels.type': 'タイプ',
|
||||
'brushType.single': '単一',
|
||||
'brushType.circle': '円',
|
||||
'labels.size': 'サイズ',
|
||||
'labels.maxAngle': '最大角度',
|
||||
'tooltips.maxAngle': '塗りつぶしが越えることができる隣接三角形間の最大二面角',
|
||||
'ui.clearAll': 'すべてクリア',
|
||||
'excl.initExcluded': '0 面マスク済み',
|
||||
'excl.faceExcluded': '{n} 面マスク済み',
|
||||
'excl.facesExcluded': '{n} 面マスク済み',
|
||||
'excl.faceSelected': '{n} 面選択済み',
|
||||
'excl.facesSelected': '{n} 面選択済み',
|
||||
'excl.hintExclude': 'マスクされたサーフェスはオレンジ色で表示され、エクスポート時にディスプレイスメントを受けません。',
|
||||
'excl.hintInclude': '選択されたサーフェスは緑色で表示され、エクスポート時にディスプレイスメントを受ける唯一の面になります。',
|
||||
|
||||
// Precision masking
|
||||
'precision.label': '精度 (ベータ) \u24d8',
|
||||
'precision.labelTitle': 'バックグラウンドでメッシュを細分化し、ブラシの選択精度を向上させます',
|
||||
'precision.outdated': '\u26a0 古い情報',
|
||||
'precision.refreshTitle': '現在のブラシサイズに合わせてメッシュを再細分化',
|
||||
'precision.triCount': '{n} \u25b3',
|
||||
'precision.refining': '精密化中\u2026',
|
||||
'precision.warningBody': '推定 ~{n} 三角形。ブラウザが遅くなる可能性があります。続行しますか?',
|
||||
|
||||
// Boundary falloff
|
||||
'labels.boundaryFalloff': 'マスクを滑らかに \u24d8',
|
||||
'tooltips.boundaryFalloff': 'マスク境界付近でディスプレイスメントを徐々にゼロに減少させ、テクスチャ適用面と非適用面の間の三角形の重なりを防ぎます。',
|
||||
|
||||
// Symmetric displacement
|
||||
'labels.symmetricDisplacement': '対称ディスプレイスメント \u24d8',
|
||||
'tooltips.symmetricDisplacement': 'オンの場合、50%グレー = 変位なし、白は外側に押し出し、黒は内側に押し込みます。部品の体積をほぼ一定に保ちます。',
|
||||
|
||||
// Displacement preview
|
||||
'labels.displacementPreview': '3Dプレビュー \u24d8',
|
||||
'tooltips.displacementPreview': 'メッシュを細分化し、リアルタイムで頂点を変位させて実際の深さを確認できます。複雑なモデルではGPU負荷が高くなります。',
|
||||
|
||||
// Place on face
|
||||
'ui.placeOnFace': '面に配置',
|
||||
'ui.placeOnFaceTitle': '面をクリックして印刷ベッドに向けて配置します',
|
||||
'progress.subdividingPreview': 'プレビューを準備中\u2026',
|
||||
|
||||
// Amplitude overlap warning
|
||||
'warnings.amplitudeOverlap': '\u26a0 振幅がモデルの最小寸法の10%を超えています \u2014 エクスポートされたSTLでジオメトリの重なりが発生する可能性があります。',
|
||||
|
||||
// Export section
|
||||
'sections.export': 'エクスポート \u24d8',
|
||||
'tooltips.export': 'エッジ長が短いほど = ディスプレイスメントの詳細度が高くなります。出力はその後三角形の上限まで削減されます。',
|
||||
'labels.resolution': '解像度',
|
||||
'tooltips.resolution': 'この値より長いエッジはエクスポート時に分割されます',
|
||||
'labels.outputTriangles': '出力三角形数',
|
||||
'tooltips.outputTriangles': 'メッシュはまず完全に細分化され、その後この数まで削減されます',
|
||||
'warnings.safetyCapHit': '\u26a0 細分化中に2000万三角形の安全制限に達しました \u2014 結果は要求されたエッジ長よりも粗くなる可能性があります。',
|
||||
'ui.exportStl': 'STLをエクスポート',
|
||||
|
||||
// Export progress stages
|
||||
'progress.subdividing': 'メッシュを細分化中\u2026',
|
||||
'progress.refining': '精密化中: {cur} 三角形、最長エッジ {edge}',
|
||||
'progress.applyingDisplacement': '{n} 三角形にディスプレイスメントを適用中\u2026',
|
||||
'progress.displacingVertices': '頂点を変位中\u2026',
|
||||
'progress.decimatingTo': '{from} → {to} 三角形に簡略化中\u2026',
|
||||
'progress.decimating': '簡略化中: {cur} → {to} 三角形',
|
||||
'progress.writingStl': 'STLを書き出し中\u2026',
|
||||
'progress.done': '完了!',
|
||||
'progress.processing': '処理中\u2026',
|
||||
|
||||
// License popup
|
||||
'license.btn': 'ライセンスと利用規約',
|
||||
'license.title': 'ライセンスと利用規約',
|
||||
'license.item1': '<strong>商用利用</strong>を含む、あらゆる目的で無料で使用できます(例:クライアントや製品向けのSTLテクスチャリング)。',
|
||||
'license.item2': 'このツールをそのまま使用する場合、クレジット表記は<strong>歓迎</strong>されますが<strong>必須ではありません</strong>。',
|
||||
'license.item3': 'このツールを支援しませんか? <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener">CNCKitchen.STORE</a>でお買い物、または<a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener">PayPal</a>で寄付できます。',
|
||||
'license.item4': 'このツールは<strong>現状のまま</strong>提供され、いかなる種類の<strong>保証もありません</strong>。自己責任でご利用ください。',
|
||||
'license.item5': '<strong>サポートは提供されません</strong>。作者にはバグの修正、質問への回答、ツールの更新の義務はありません。ただし、バグ報告や機能リクエストは <a href="mailto:texturizer@cnckitchen.com">texturizer@cnckitchen.com</a> までいつでも歓迎します。',
|
||||
'license.item6': '作者は、このツールの使用に起因する<strong>損害</strong>、データ損失、またはその他の問題について責任を負いません。',
|
||||
'license.item7': 'このツールをビジネスやウェブサイトにライセンスまたは組み込みたい場合は、<a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a> までお問い合わせください。',
|
||||
'license.item8': 'ソースコードは <a href="https://github.com/CNCKitchen/stlTexturizer" target="_blank" rel="noopener">GitHub</a> で公開されています。',
|
||||
|
||||
// Imprint & Privacy
|
||||
'imprint.btn': '特定商取引法に基づく表記とプライバシー',
|
||||
'imprint.title': '特定商取引法に基づく表記とプライバシーポリシー',
|
||||
'imprint.sectionImprint': '運営者情報 (Impressum)',
|
||||
'imprint.info': 'CNC Kitchen<br>Stefan Hermann<br>Bahnhofstr. 2<br>88145 Hergatz<br>ドイツ',
|
||||
'imprint.contact': 'メール: <a href="mailto:contact@cnckitchen.com">contact@cnckitchen.com</a><br>電話: +49 175 2011824<br><em>電話番号は法律/ビジネスに関するお問い合わせ専用です \u2014 サポートには対応しておりません。</em>',
|
||||
'imprint.odr': 'EU オンライン紛争解決プラットフォーム: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr</a>',
|
||||
'imprint.sectionPrivacy': 'プライバシーポリシー (Datenschutzerklärung)',
|
||||
'imprint.privacyIntro': '<strong>責任者</strong> (Verantwortlicher gem. Art. 4 Abs. 7 DSGVO): Stefan Hermann, Bahnhofstr. 2, 88145 Hergatz, ドイツ。',
|
||||
'imprint.privacyHosting': 'このウェブサイトは <strong>GitHub Pages</strong>(GitHub Inc. / Microsoft Corp., 88 Colin P Kelly Jr St, San Francisco, CA 94107, USA)でホストされています。このサイトにアクセスすると、GitHubがサーバーログでIPアドレスを処理する場合があります。法的根拠: GDPR第6条(1)(f)(ウェブサイト提供の正当な利益)。<a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener">GitHubのプライバシーステートメント</a>を参照してください。',
|
||||
'imprint.privacyLocal': 'このツールはユーザーの設定(言語、テーマ)をブラウザの<strong>localStorage</strong>に保存します。このデータはデバイスの外に出ることはなく、サーバーに送信されることもありません。',
|
||||
'imprint.privacyNoCookies': 'このウェブサイトはCookie、分析ツール、トラッキング技術を<strong>一切使用しません</strong>。',
|
||||
'imprint.privacyExternal': 'このサイトには外部ウェブサイト(CNCKitchen.STORE、PayPalなど)へのリンクが含まれています。これらのサイトには独自のプライバシーポリシーがあり、当方では管理できません。',
|
||||
'imprint.privacyRights': 'GDPRに基づき、<strong>アクセス、訂正、削除、処理の制限、データポータビリティ</strong>の権利、および監督機関に<strong>苦情を申し立てる</strong>権利があります。',
|
||||
|
||||
// Sponsor modal
|
||||
'sponsor.title': 'CNC Kitchen の BumpMesh をご利用いただきありがとうございます!',
|
||||
'sponsor.body': 'このツールは CNC Kitchen が<strong>完全無料</strong>で提供しています。<br>STLの処理中に、私たちがクールなものを作り続けるのを支えてくれるストアを覗いてみませんか?',
|
||||
'sponsor.visitStore': '\uD83D\uDED2 CNCKitchen.STORE を訪問',
|
||||
'sponsor.donate': '\uD83D\uDC99 PayPal で寄付',
|
||||
'sponsor.dontShow': '今後表示しない',
|
||||
'sponsor.closeAndContinue': '閉じて続行',
|
||||
|
||||
// Store CTA
|
||||
'cta.store': 'このツールを支援しませんか? <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a>でお買い物、または<a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a>で寄付できます',
|
||||
'cta.storeDismiss': '閉じる',
|
||||
|
||||
// Alerts
|
||||
'alerts.loadFailed': 'モデルを読み込めませんでした: {msg}',
|
||||
'alerts.exportFailed': 'エクスポートに失敗しました: {msg}',
|
||||
'alerts.fileTooLarge': 'ファイルが大きすぎます ({size} MB)。最大: {max} MB。',
|
||||
},
|
||||
};
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
@@ -441,11 +1234,16 @@ export function initLang() {
|
||||
const saved = localStorage.getItem('stlt-lang');
|
||||
if (saved && TRANSLATIONS[saved]) {
|
||||
_currentLang = saved;
|
||||
} else if (navigator.language && navigator.language.toLowerCase().startsWith('de')) {
|
||||
_currentLang = 'de';
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
const lang = navigator.language.split('-')[0];
|
||||
if (TRANSLATIONS[lang]) {
|
||||
_currentLang = lang;
|
||||
}
|
||||
else {
|
||||
_currentLang = 'en';
|
||||
}
|
||||
}
|
||||
document.documentElement.setAttribute('data-lang', _currentLang);
|
||||
document.documentElement.setAttribute('lang', _currentLang);
|
||||
applyTranslations();
|
||||
|
||||
+436
-38
@@ -12,7 +12,7 @@ import { decimate } from './decimation.js';
|
||||
import { exportSTL } from './exporter.js';
|
||||
import { buildAdjacency, bucketFill,
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,12 @@ let previewMaterial = null;
|
||||
let isExporting = false;
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
let excludedFaces = new Set(); // triangle indices in currentGeometry
|
||||
let triangleAdjacency = null; // Array from buildAdjacency
|
||||
@@ -62,6 +68,7 @@ const settings = {
|
||||
seamBandWidth: 0.5,
|
||||
textureSmoothing: 0,
|
||||
capAngle: 20,
|
||||
boundaryFalloff: 0,
|
||||
symmetricDisplacement: false,
|
||||
useDisplacement: false,
|
||||
};
|
||||
@@ -207,6 +214,8 @@ const textureSmoothingVal = document.getElementById('texture-smoothing-val');
|
||||
const capAngleSlider = document.getElementById('cap-angle');
|
||||
const capAngleVal = document.getElementById('cap-angle-val');
|
||||
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 dispPreviewToggle = document.getElementById('displacement-preview');
|
||||
|
||||
@@ -245,6 +254,9 @@ const imprintLink = document.getElementById('imprint-link');
|
||||
const imprintOverlay = document.getElementById('imprint-overlay');
|
||||
const imprintClose = document.getElementById('imprint-close');
|
||||
|
||||
// ── Language selector DOM refs ────────────────────────────────────────────────────
|
||||
const languageSelector = document.querySelector('.lang-seg');
|
||||
|
||||
// ── Scale slider log helpers ──────────────────────────────────────────────────
|
||||
// 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).
|
||||
@@ -271,15 +283,46 @@ initViewer(canvas);
|
||||
// Apply saved theme to 3D viewport on startup
|
||||
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)
|
||||
initLang();
|
||||
|
||||
// Sync lang buttons to current language
|
||||
// Sync lang dropdown to current language
|
||||
(function() {
|
||||
const lang = getLang();
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.langCode === lang);
|
||||
});
|
||||
const select = languageSelector.querySelector('select');
|
||||
if (select) {
|
||||
select.value = lang;
|
||||
}
|
||||
})();
|
||||
|
||||
// Theme toggle
|
||||
@@ -380,22 +423,6 @@ function trapFocus(overlay) {
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 ──
|
||||
stlFileInput.addEventListener('change', (e) => {
|
||||
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(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); });
|
||||
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(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; });
|
||||
linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; _falloffDirty = true; 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(seamBandWidthSlider, seamBandWidthVal, v => { settings.seamBandWidth = v; return v.toFixed(2); });
|
||||
linkSlider(textureSmoothingSlider, textureSmoothingVal, v => { settings.textureSmoothing = v; return v.toFixed(1); });
|
||||
@@ -718,7 +746,7 @@ function wireEvents() {
|
||||
if (placeOnFaceActive && currentGeometry) { updatePlaceOnFaceHover(ev); return; }
|
||||
if (exclusionTool === 'brush') {
|
||||
updateBrushCursor(ev);
|
||||
if (brushIsRadius && !isPainting && currentGeometry) updateBrushHover(ev);
|
||||
if (!isPainting && currentGeometry) updateBrushHover(ev);
|
||||
_updateShiftLinePreview(ev);
|
||||
} else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) {
|
||||
updateBucketHover(ev);
|
||||
@@ -809,6 +837,14 @@ function setExclusionTool(tool) {
|
||||
if (!exclusionTool) {
|
||||
isPainting = false;
|
||||
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);
|
||||
|
||||
// Update edge length for new bounds
|
||||
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
|
||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
||||
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, +(diag / 300).toFixed(2)));
|
||||
settings.refineLength = defaultEdge;
|
||||
refineLenSlider.value = defaultEdge;
|
||||
refineLenVal.value = defaultEdge;
|
||||
@@ -1258,12 +1294,11 @@ function refreshExclusionOverlay() {
|
||||
const overlayGeo = usePrecision ? precisionGeometry : currentGeometry;
|
||||
const overlayFaceSet = usePrecision ? precisionExcludedFaces : excludedFaces;
|
||||
|
||||
if (selectionMode) {
|
||||
const maskGeo = buildExclusionOverlayGeo(overlayGeo, overlayFaceSet, true);
|
||||
setExclusionOverlay(maskGeo, 0x8ab4d4, 0.96);
|
||||
} else {
|
||||
setExclusionOverlay(buildExclusionOverlayGeo(overlayGeo, overlayFaceSet), 0xff6600);
|
||||
}
|
||||
_falloffDirty = true;
|
||||
|
||||
// Never show the flat-coloured MeshLambertMaterial overlay — the custom
|
||||
// shader handles mask visualisation with smooth, view-dependent shading.
|
||||
setExclusionOverlay(null);
|
||||
const n = usePrecision ? precisionExcludedFaces.size : excludedFaces.size;
|
||||
exclCount.textContent = selectionMode
|
||||
? 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;
|
||||
triLimitWarning.classList.add('hidden');
|
||||
|
||||
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
|
||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
||||
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, +(diag / 250).toFixed(2)));
|
||||
settings.refineLength = defaultEdge;
|
||||
refineLenSlider.value = defaultEdge;
|
||||
refineLenVal.value = defaultEdge;
|
||||
@@ -1582,9 +1617,9 @@ async function handleModelFile(file) {
|
||||
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
|
||||
triLimitWarning.classList.add('hidden');
|
||||
|
||||
// Default edge length = 1/200 of the largest bounding box dimension
|
||||
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
|
||||
const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2)));
|
||||
// Default edge length = 1/250 of the bounding box diagonal
|
||||
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, +(diag / 250).toFixed(2)));
|
||||
settings.refineLength = defaultEdge;
|
||||
refineLenSlider.value = defaultEdge;
|
||||
refineLenVal.value = defaultEdge;
|
||||
@@ -1669,9 +1704,360 @@ function updateFaceMask(geometry) {
|
||||
if (!geometry.attributes.faceNormal) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* using a grid-accelerated nearest-centroid lookup, with face normal
|
||||
@@ -1887,6 +2273,7 @@ function updatePreview() {
|
||||
updateMaterial(previewMaterial, effectiveEntry.texture, fullSettings);
|
||||
}
|
||||
|
||||
syncBoundaryEdgeUniforms();
|
||||
exportBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -2046,7 +2433,6 @@ function deactivatePrecisionMasking() {
|
||||
excludedFaces = precisionExcludedFaces;
|
||||
|
||||
// Update mesh info display
|
||||
const triCount = getTriangleCount(currentGeometry);
|
||||
const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
|
||||
const sx = currentBounds.size.x.toFixed(2);
|
||||
const sy = currentBounds.size.y.toFixed(2);
|
||||
@@ -2138,6 +2524,18 @@ async function refreshPrecisionMesh() {
|
||||
// Swap display mesh to refined geometry
|
||||
setMeshGeometry(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();
|
||||
else setExclusionOverlay(null);
|
||||
|
||||
|
||||
+84
-7
@@ -202,12 +202,17 @@ const vertexShader = /* glsl */`
|
||||
attribute vec3 smoothNormal;
|
||||
attribute vec3 faceNormal;
|
||||
attribute float faceMask;
|
||||
attribute float boundaryFalloffAttr;
|
||||
attribute float boundaryMaskTypeAttr;
|
||||
|
||||
varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment
|
||||
varying vec3 vModelNormal; // model-space face normal → stable UV blending
|
||||
varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular
|
||||
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() {
|
||||
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);
|
||||
if (fN.z >= 0.0 && topAngleLimit >= 1.0)
|
||||
angleMask = min(angleMask, surfaceAngle > topAngleLimit ? 1.0 : 0.0);
|
||||
float totalMask = angleMask * faceMask;
|
||||
float totalMask = angleMask * faceMask * boundaryFalloffAttr;
|
||||
vFaceMask = totalMask;
|
||||
vUserMask = faceMask;
|
||||
vMaskType = boundaryMaskTypeAttr;
|
||||
|
||||
if (useDisplacement == 1) {
|
||||
float h = computeHeightAtPoint(position, safeN, safeN);
|
||||
@@ -243,6 +250,8 @@ const vertexShader = /* glsl */`
|
||||
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
|
||||
vViewPos = mvPos.xyz;
|
||||
vNormal = normalize(normalMatrix * fN);
|
||||
vec3 sN = length(smoothNormal) > 1e-6 ? normalize(smoothNormal) : safeN;
|
||||
vSmoothNormal = normalize(normalMatrix * sN);
|
||||
gl_Position = projectionMatrix * mvPos;
|
||||
}
|
||||
`;
|
||||
@@ -251,11 +260,19 @@ const fragmentShader = /* glsl */`
|
||||
precision highp float;
|
||||
${sharedGLSL}
|
||||
|
||||
uniform sampler2D boundaryEdgeTex;
|
||||
uniform int boundaryEdgeCount;
|
||||
uniform float boundaryEdgeTexWidth;
|
||||
uniform float boundaryFalloffDist;
|
||||
|
||||
varying vec3 vModelPos;
|
||||
varying vec3 vModelNormal;
|
||||
varying vec3 vViewPos;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vSmoothNormal;
|
||||
varying float vFaceMask;
|
||||
varying float vUserMask;
|
||||
varying float vMaskType;
|
||||
|
||||
// Fragment-only wrapper: compute face-stable projection normal via dFdx
|
||||
// then delegate to the shared height function.
|
||||
@@ -282,6 +299,27 @@ const fragmentShader = /* glsl */`
|
||||
|
||||
// ── Combined mask (angle + user exclusion) from vertex shader ────────
|
||||
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;
|
||||
dhx *= maskBlend;
|
||||
dhy *= maskBlend;
|
||||
@@ -306,8 +344,20 @@ const fragmentShader = /* glsl */`
|
||||
vec3 bumpVec = N - bumpStr * (dhx * T + dhy * B);
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
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 L2 = normalize(vec3(-0.5, -0.2, -0.6));
|
||||
@@ -319,10 +369,23 @@ const fragmentShader = /* glsl */`
|
||||
vec3 H1 = normalize(L1 + V);
|
||||
float spec = pow(max(dot(bumpN, H1), 0.0), 64.0) * 0.60;
|
||||
|
||||
vec3 color = baseColor * 0.55 // ambient
|
||||
+ baseColor * diff1 * vec3(1.00, 0.96, 0.88) * 0.55 // key light
|
||||
+ baseColor * diff2 * vec3(0.80, 0.60, 0.50) * 0.15 // warm fill
|
||||
+ vec3(spec); // specular
|
||||
// Lit teal (identical for textured and masked surfaces)
|
||||
vec3 litTeal = tealBase * 0.55
|
||||
+ tealBase * diff1 * vec3(1.00, 0.96, 0.88) * 0.55
|
||||
+ 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);
|
||||
}
|
||||
@@ -371,6 +434,7 @@ export function updateMaterial(material, displacementTexture, settings) {
|
||||
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
|
||||
u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
|
||||
u.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1);
|
||||
u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0.0;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────────
|
||||
@@ -399,6 +463,10 @@ function buildUniforms(tex, settings) {
|
||||
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
|
||||
useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
.lang-dropdown {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
padding: 0 24px 0 10px; /* Add right padding for default select arrow if present */
|
||||
background: var(--surface2);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
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;
|
||||
}
|
||||
|
||||
.lang-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
.lang-dropdown:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
.lang-dropdown:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Theme toggle button ─────────────────────────────────────────────── */
|
||||
@@ -391,6 +391,15 @@ main {
|
||||
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 {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user