// ── 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 .stl file here
or ',
// 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 completely free by CNC Kitchen.
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 & 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': '.stl-Datei hier ablegen
oder ',
// 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 komplett kostenlos bereitgestellt.
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 & 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));
});
//