added different languages

This commit is contained in:
CNCKitchen
2026-03-18 12:47:49 +01:00
parent ac598757f0
commit 3974b30b56
7 changed files with 493 additions and 74 deletions
+316
View File
@@ -0,0 +1,316 @@
// ── Translations ──────────────────────────────────────────────────────────────
// All UI strings in one place. Use {placeholder} syntax for dynamic values.
export const TRANSLATIONS = {
en: {
// Theme toggle
'theme.dark': 'Dark Theme',
'theme.light': 'Light Theme',
'theme.toggleTitle': 'Toggle light / dark mode',
'theme.toggleAriaLabel': 'Toggle light/dark mode',
// Drop zone
'dropHint.text': 'Drop an <strong>.stl</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label>',
// Viewport footer
'ui.wireframe': 'Wireframe',
'ui.controlsHint': 'Left drag: orbit \u00a0·\u00a0 Right drag: pan \u00a0·\u00a0 Scroll: zoom',
'ui.meshInfo': '{n} triangles · {mb} MB',
// Load STL button
'ui.loadStl': 'Load STL\u2026',
// Displacement map section
'sections.displacementMap': 'Displacement Map',
'ui.uploadCustomMap': 'Upload custom map',
'ui.noMapSelected': 'No map selected',
// Projection section
'sections.projection': 'Projection',
'labels.mode': 'Mode',
'projection.triplanar': 'Triplanar',
'projection.cubic': 'Cubic (Box)',
'projection.cylindrical':'Cylindrical',
'projection.spherical': 'Spherical',
'projection.planarXY': 'Planar XY',
'projection.planarXZ': 'Planar XZ',
'projection.planarYZ': 'Planar YZ',
// Transform section
'sections.transform': 'Transform',
'labels.scaleU': 'Scale U',
'labels.scaleV': 'Scale V',
'labels.offsetU': 'Offset U',
'labels.offsetV': 'Offset V',
'labels.rotation': 'Rotation',
'tooltips.proportionalScaling': 'Proportional scaling (U = V)',
'tooltips.proportionalScalingAria': 'Proportional scaling (U = V)',
// Displacement section
'sections.displacement': 'Displacement',
'labels.amplitude': 'Amplitude',
// Surface mask section
'sections.surfaceMask': 'Surface Mask \u24d8',
'tooltips.surfaceMask': '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',
'labels.topFaces': 'Top faces',
'tooltips.topFaces': 'Suppress texture on upward-facing surfaces within this angle of horizontal',
// Surface exclusions section
'sections.surfaceExclusions': 'Surface Exclusions \u24d8',
'sections.surfaceSelection': 'Surface Selection',
'tooltips.surfaceExclusions': 'Excluded surfaces appear orange and will not receive displacement during export.',
'tooltips.surfaceSelection': 'Selected surfaces appear green and will be the only ones to receive displacement during export.',
'excl.modeExclude': 'Exclude',
'excl.modeExcludeTitle': 'Exclude mode: painted surfaces will not receive texture displacement',
'excl.modeIncludeOnly': 'Include Only',
'excl.modeIncludeOnlyTitle': 'Include Only mode: only painted surfaces will receive texture displacement',
'excl.toolBrush': 'Brush',
'excl.toolBrushTitle': 'Brush: paint triangles to exclude',
'excl.toolFill': 'Fill',
'excl.toolFillTitle': 'Bucket fill: flood-fill surface up to a threshold angle',
'excl.toolErase': 'Erase',
'excl.toolEraseTitle': 'Toggle: mark or erase mode',
'labels.type': 'Type',
'brushType.single': 'Single',
'brushType.radius': 'Radius',
'labels.radius': 'Radius',
'labels.maxAngle': 'Max angle',
'tooltips.maxAngle': 'Maximum dihedral angle between adjacent triangles for the fill to cross',
'ui.clearAll': 'Clear All',
'excl.initExcluded': '0 faces excluded',
'excl.faceExcluded': '{n} face excluded',
'excl.facesExcluded': '{n} faces excluded',
'excl.faceSelected': '{n} face selected',
'excl.facesSelected': '{n} faces selected',
'excl.hintExclude': 'Excluded surfaces appear orange and will not receive displacement during export.',
'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.',
// Export section
'sections.export': 'Export \u24d8',
'tooltips.export': 'Smaller edge length = finer displacement detail. Output is then decimated to the triangle limit.',
'labels.resolution': 'Resolution',
'tooltips.resolution': 'Edges longer than this value will be split during export',
'labels.outputTriangles': 'Output Triangles',
'tooltips.outputTriangles': 'Mesh is fully subdivided first, then decimated down to this count',
'warnings.safetyCapHit': '\u26a0 5M-triangle safety cap hit during subdivision \u2014 result may still be coarser than requested edge length.',
'ui.exportStl': 'Export STL',
// Export progress stages
'progress.subdividing': 'Subdividing mesh\u2026',
'progress.applyingDisplacement': 'Applying displacement to {n} triangles\u2026',
'progress.displacingVertices': 'Displacing vertices\u2026',
'progress.decimatingTo': 'Decimating {from} \u2192 {to} triangles\u2026',
'progress.decimating': 'Decimating: {cur} \u2192 {to} triangles',
'progress.writingStl': 'Writing STL\u2026',
'progress.done': 'Done!',
'progress.processing': 'Processing\u2026',
// Sponsor modal
'sponsor.title': 'Thanks for using CNC Kitchen STL Texturizer!',
'sponsor.body': 'This tool is provided <strong>completely free</strong> by CNC Kitchen.<br>While your STL is being processed, why not check out the store that helps us keep making cool stuff for you?',
'sponsor.visitStore': '\uD83D\uDED2 Visit CNCKitchen.STORE',
'sponsor.dontShow': "Don\u2019t show this again",
'sponsor.closeAndContinue':'Close &amp; Continue',
// Alerts
'alerts.loadFailed': 'Could not load STL: {msg}',
'alerts.exportFailed': 'Export failed: {msg}',
},
de: {
// Theme toggle
'theme.dark': 'Dunkles Design',
'theme.light': 'Helles Design',
'theme.toggleTitle': 'Hell/Dunkel-Modus wechseln',
'theme.toggleAriaLabel': 'Hell/Dunkel-Modus wechseln',
// Drop zone
'dropHint.text': '<strong>.stl</strong>-Datei hier ablegen<br/>oder <label for="stl-file-input" class="link-label">zum Durchsuchen klicken</label>',
// Viewport footer
'ui.wireframe': 'Drahtgitter',
'ui.controlsHint': 'Links ziehen: Drehen \u00a0·\u00a0 Rechts ziehen: Verschieben \u00a0·\u00a0 Scrollen: Zoomen',
'ui.meshInfo': '{n} Dreiecke · {mb} MB',
// Load STL button
'ui.loadStl': 'STL laden\u2026',
// Displacement map section
'sections.displacementMap': 'Verschiebungstextur',
'ui.uploadCustomMap': 'Eigene Textur hochladen',
'ui.noMapSelected': 'Keine Textur ausgew\u00e4hlt',
// Projection section
'sections.projection': 'Projektion',
'labels.mode': 'Modus',
'projection.triplanar': 'Triplanar',
'projection.cubic': 'Kubisch (Box)',
'projection.cylindrical':'Zylindrisch',
'projection.spherical': 'Sph\u00e4risch',
'projection.planarXY': 'Planar XY',
'projection.planarXZ': 'Planar XZ',
'projection.planarYZ': 'Planar YZ',
// Transform section
'sections.transform': 'Transformation',
'labels.scaleU': 'Skalierung U',
'labels.scaleV': 'Skalierung V',
'labels.offsetU': 'Versatz U',
'labels.offsetV': 'Versatz V',
'labels.rotation': 'Rotation',
'tooltips.proportionalScaling': 'Proportionale Skalierung (U = V)',
'tooltips.proportionalScalingAria': 'Proportionale Skalierung (U = V)',
// Displacement section
'sections.displacement': 'Verschiebung',
'labels.amplitude': 'Amplitude',
// Surface mask section
'sections.surfaceMask': 'Fl\u00e4chenmaske \u24d8',
'tooltips.surfaceMask': '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',
'labels.topFaces': 'Oberseiten',
'tooltips.topFaces': 'Textur auf nach oben gerichteten Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen unterdr\u00fccken',
// Surface exclusions section
'sections.surfaceExclusions': 'Fl\u00e4chenausschluss \u24d8',
'sections.surfaceSelection': 'Fl\u00e4chenauswahl',
'tooltips.surfaceExclusions': 'Ausgeschlossene Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.',
'tooltips.surfaceSelection': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.',
'excl.modeExclude': 'Ausschlie\u00dfen',
'excl.modeExcludeTitle': 'Ausschlussmodus: bemalte Fl\u00e4chen erhalten keine Texturverschiebung',
'excl.modeIncludeOnly': 'Nur einschlie\u00dfen',
'excl.modeIncludeOnlyTitle': 'Nur-einschlie\u00dfen-Modus: nur bemalte Fl\u00e4chen erhalten Texturverschiebung',
'excl.toolBrush': 'Pinsel',
'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.toolErase': 'Radieren',
'excl.toolEraseTitle': 'Umschalten: Markieren oder Radieren',
'labels.type': 'Typ',
'brushType.single': 'Einzeln',
'brushType.radius': 'Radius',
'labels.radius': 'Radius',
'labels.maxAngle': 'Max. Winkel',
'tooltips.maxAngle': 'Maximaler Di\u00e4dralwinkel zwischen angrenzenden Dreiecken f\u00fcr die F\u00fcllung',
'ui.clearAll': 'Alles l\u00f6schen',
'excl.initExcluded': '0 Fl\u00e4chen ausgeschlossen',
'excl.faceExcluded': '{n} Fl\u00e4che ausgeschlossen',
'excl.facesExcluded': '{n} Fl\u00e4chen ausgeschlossen',
'excl.faceSelected': '{n} Fl\u00e4che ausgew\u00e4hlt',
'excl.facesSelected': '{n} Fl\u00e4chen ausgew\u00e4hlt',
'excl.hintExclude': 'Ausgeschlossene Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.',
'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.',
// Export section
'sections.export': 'Export \u24d8',
'tooltips.export': 'Kleinere Kantenl\u00e4nge = feineres Verschiebungsdetail. Die Ausgabe wird dann auf das Dreieckslimit dezimiert.',
'labels.resolution': 'Aufl\u00f6sung',
'tooltips.resolution': 'Kanten l\u00e4nger als dieser Wert werden beim Export unterteilt',
'labels.outputTriangles': 'Ausgabe-Dreiecke',
'tooltips.outputTriangles': 'Das Netz wird zuerst vollst\u00e4ndig unterteilt, dann auf diese Anzahl dezimiert',
'warnings.safetyCapHit': '\u26a0 5-Mio.-Dreiecke-Sicherheitsgrenze bei der Unterteilung erreicht \u2014 Ergebnis kann gr\u00f6ber als gew\u00fcnschte Kantenl\u00e4nge sein.',
'ui.exportStl': 'STL exportieren',
// Export progress stages
'progress.subdividing': 'Netz wird unterteilt\u2026',
'progress.applyingDisplacement': 'Verschiebung auf {n} Dreiecke anwenden\u2026',
'progress.displacingVertices': 'Vertices werden verschoben\u2026',
'progress.decimatingTo': '{from} \u2192 {to} Dreiecke dezimieren\u2026',
'progress.decimating': 'Dezimieren: {cur} \u2192 {to} Dreiecke',
'progress.writingStl': 'STL schreiben\u2026',
'progress.done': 'Fertig!',
'progress.processing': 'Verarbeitung\u2026',
// Sponsor modal
'sponsor.title': 'Danke f\u00fcr die Nutzung des CNC Kitchen STL Texturizers!',
'sponsor.body': 'Dieses Tool wird von CNC Kitchen <strong>komplett kostenlos</strong> bereitgestellt.<br>W\u00e4hrend dein STL verarbeitet wird, schau doch mal im Shop vorbei, der uns hilft, coole Sachen f\u00fcr dich zu machen!',
'sponsor.visitStore': '\uD83D\uDED2 CNCKitchen.STORE besuchen',
'sponsor.dontShow': 'Nicht mehr anzeigen',
'sponsor.closeAndContinue':'Schlie\u00dfen &amp; Weiter',
// Alerts
'alerts.loadFailed': 'STL konnte nicht geladen werden: {msg}',
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
},
};
// ── State ─────────────────────────────────────────────────────────────────────
let _currentLang = 'en';
// ── Core API ──────────────────────────────────────────────────────────────────
/**
* Look up a translation key in the current language, falling back to English.
* Replace {placeholder} tokens with values from `params`.
*/
export function t(key, params = {}) {
const lang = TRANSLATIONS[_currentLang] || TRANSLATIONS.en;
let str = lang[key] ?? TRANSLATIONS.en[key] ?? key;
for (const [k, v] of Object.entries(params)) {
str = str.replaceAll(`{${k}}`, v);
}
return str;
}
export function getLang() {
return _currentLang;
}
export function setLang(lang) {
if (!TRANSLATIONS[lang]) return;
_currentLang = lang;
localStorage.setItem('stlt-lang', lang);
document.documentElement.setAttribute('data-lang', lang);
document.documentElement.setAttribute('lang', lang);
applyTranslations();
}
/**
* Walk the DOM and apply translations to elements carrying data-i18n* attributes.
*/
export function applyTranslations() {
// textContent
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
// innerHTML (safe: all values are hardcoded in this file, not user input)
document.querySelectorAll('[data-i18n-html]').forEach(el => {
el.innerHTML = t(el.dataset.i18nHtml);
});
// title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
// aria-label attribute
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
});
// <option> elements (textContent doesn't work via data-i18n on options in some browsers)
document.querySelectorAll('option[data-i18n-opt]').forEach(opt => {
opt.textContent = t(opt.dataset.i18nOpt);
});
}
/**
* Detect language from localStorage or browser preference and apply.
* Call once on startup before first render.
*/
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 {
_currentLang = 'en';
}
document.documentElement.setAttribute('data-lang', _currentLang);
document.documentElement.setAttribute('lang', _currentLang);
applyTranslations();
}
+47 -17
View File
@@ -11,6 +11,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';
// ── State ─────────────────────────────────────────────────────────────────────
@@ -132,6 +133,17 @@ initViewer(canvas);
// Apply saved theme to 3D viewport on startup
setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light');
// Initialise language (reads localStorage / browser preference, applies translations)
initLang();
// Sync lang buttons to current language
(function() {
const lang = getLang();
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.langCode === lang);
});
})();
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', () => {
const isLight = document.documentElement.getAttribute('data-theme') !== 'light';
@@ -187,6 +199,22 @@ function selectPreset(idx, swatchEl) {
// ── 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();
});
});
// ── STL loading ──
stlFileInput.addEventListener('change', (e) => {
if (e.target.files[0]) handleSTL(e.target.files[0]);
@@ -429,10 +457,10 @@ function setSelectionMode(include) {
exclModeIncludeBtn.classList.toggle('active', selectionMode);
exclModeExcludeBtn.setAttribute('aria-pressed', String(!selectionMode));
exclModeIncludeBtn.setAttribute('aria-pressed', String(selectionMode));
exclSectionHeading.textContent = selectionMode ? 'Surface Selection' : 'Surface Exclusions';
exclSectionHeading.textContent = selectionMode ? t('sections.surfaceSelection') : t('sections.surfaceExclusions');
exclHint.textContent = selectionMode
? 'Selected surfaces appear green and will be the only ones to receive displacement during export.'
: 'Excluded surfaces appear orange and will not receive displacement during export.';
? t('excl.hintInclude')
: t('excl.hintExclude');
// Clear the painted set — faces had opposite semantics in the previous mode
excludedFaces = new Set();
refreshExclusionOverlay();
@@ -537,8 +565,8 @@ function refreshExclusionOverlay() {
}
const n = excludedFaces.size;
exclCount.textContent = selectionMode
? `${n.toLocaleString()} face${n === 1 ? '' : 's'} selected`
: `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
? t(n === 1 ? 'excl.faceSelected' : 'excl.facesSelected', { n: n.toLocaleString() })
: t(n === 1 ? 'excl.faceExcluded' : 'excl.facesExcluded', { n: n.toLocaleString() });
}
function updateBrushCursor(e) {
@@ -676,7 +704,7 @@ async function handleSTL(file) {
setExclusionOverlay(null);
setHoverPreview(null);
_lastHoverTriIdx = -1;
exclCount.textContent = '0 faces excluded';
exclCount.textContent = t('excl.initExcluded');
// Build adjacency data for brush/bucket tools (synchronous; fast enough for
// typical STL sizes processed by this tool)
const adjData = buildAdjacency(geometry);
@@ -703,13 +731,13 @@ async function handleSTL(file) {
const triCount = getTriangleCount(geometry);
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
meshInfo.textContent = `${triCount.toLocaleString()} triangles · ${mb} MB`;
meshInfo.textContent = t('ui.meshInfo', { n: triCount.toLocaleString(), mb });
exportBtn.disabled = (activeMapEntry === null);
updatePreview();
} catch (err) {
console.error('Failed to load STL:', err);
alert(`Could not load STL: ${err.message}`);
alert(t('alerts.loadFailed', { msg: err.message }));
}
}
@@ -792,7 +820,7 @@ async function handleExport() {
exportProgress.classList.remove('hidden');
try {
setProgress(0.02, 'Subdividing mesh…');
setProgress(0.02, t('progress.subdividing'));
await yieldFrame();
// Build per-vertex exclusion weights combining user-painted exclusion + angle masking.
@@ -806,12 +834,12 @@ async function handleExport() {
const { geometry: subdivided, safetyCapHit } = await subdivide(
currentGeometry, settings.refineLength,
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'),
(p) => setProgress(0.02 + p * 0.35, t('progress.subdividing')),
faceWeights
);
const subTriCount = subdivided.attributes.position.count / 3;
setProgress(0.38, `Applying displacement to ${subTriCount.toLocaleString()} triangles…`);
setProgress(0.38, t('progress.applyingDisplacement', { n: subTriCount.toLocaleString() }));
const displaced = await runAsync(() =>
applyDisplacement(
@@ -821,17 +849,19 @@ async function handleExport() {
activeMapEntry.height,
settings,
currentBounds,
(p) => setProgress(0.38 + p * 0.32, `Displacing vertices`)
(p) => setProgress(0.38 + p * 0.32, t('progress.displacingVertices'))
)
);
const dispTriCount = displaced.attributes.position.count / 3;
const needsDecimation = dispTriCount > settings.maxTriangles;
triLimitWarning.classList.toggle('hidden', !safetyCapHit);
// Re-apply translated warning text in case language changed since last export
triLimitWarning.textContent = t('warnings.safetyCapHit');
let finalGeometry = displaced;
if (needsDecimation) {
setProgress(0.71, `Decimating ${dispTriCount.toLocaleString()}${settings.maxTriangles.toLocaleString()} triangles…`);
setProgress(0.71, t('progress.decimatingTo', { from: dispTriCount.toLocaleString(), to: settings.maxTriangles.toLocaleString() }));
finalGeometry = await runAsync(() =>
decimate(
displaced,
@@ -840,7 +870,7 @@ async function handleExport() {
const cur = Math.round(dispTriCount - (dispTriCount - settings.maxTriangles) * p);
setProgress(
0.71 + p * 0.25,
`Decimating: ${cur.toLocaleString()}${settings.maxTriangles.toLocaleString()} triangles`
t('progress.decimating', { cur: cur.toLocaleString(), to: settings.maxTriangles.toLocaleString() })
);
}
)
@@ -873,7 +903,7 @@ async function handleExport() {
else finalGeometry.attributes.normal.needsUpdate = true;
}
setProgress(0.97, 'Writing STL…');
setProgress(0.97, t('progress.writingStl'));
await yieldFrame();
const texLabel = activeMapEntry.isCustom ? 'custom' : activeMapEntry.name.replace(/\s+/g, '-');
@@ -881,14 +911,14 @@ async function handleExport() {
const exportName = `${currentStlName}_${texLabel}_amp${ampLabel}.stl`;
exportSTL(finalGeometry, exportName);
setProgress(1.0, 'Done!');
setProgress(1.0, t('progress.done'));
setTimeout(() => {
exportProgress.classList.add('hidden');
setProgress(0, '');
}, 1500);
} catch (err) {
console.error('Export failed:', err);
alert(`Export failed: ${err.message}`);
alert(t('alerts.exportFailed', { msg: err.message }));
exportProgress.classList.add('hidden');
} finally {
isExporting = false;