// ── 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, .obj or .3mf 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 · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'Load Model\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': 'Texture Depth',
'labels.amplitude': 'Amplitude',
// Seam blend
'labels.seamBlend': 'Seam Blend \u24d8',
'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.',
'labels.transitionSmoothing': 'Transition Smoothing \u24d8',
'tooltips.transitionSmoothing': 'Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.',
'labels.textureSmoothing': 'Texture Smoothing \u24d8',
'tooltips.textureSmoothing': 'Applies a Gaussian blur to the displacement map. Higher values produce softer, more gradual surface detail. 0 = off.',
'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.',
// Mask angles section
'sections.maskAngles': 'Mask Angles \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',
'labels.topFaces': 'Top faces',
'tooltips.topFaces': 'Suppress texture on upward-facing surfaces within this angle of horizontal',
// Surface masking section
'sections.surfaceMasking': 'Surface Masking \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.',
'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.shiftHint': 'Hold Shift to erase',
'labels.type': 'Type',
'brushType.single': 'Single',
'brushType.circle': 'Circle',
'labels.size': 'Size',
'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 masked',
'excl.faceExcluded': '{n} face masked',
'excl.facesExcluded': '{n} faces masked',
'excl.faceSelected': '{n} face selected',
'excl.facesSelected': '{n} faces selected',
'excl.hintExclude': 'Masked 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.',
// 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.',
// Displacement preview
'labels.displacementPreview': '3D Preview \u24d8',
'tooltips.displacementPreview': 'Subdivides the mesh and displaces vertices in real-time so you can judge the actual depth. GPU-intensive on complex models.',
// Place on face
'ui.placeOnFace': 'Place on Face',
'ui.placeOnFaceTitle': 'Click a face to orient it downward onto the print bed',
'progress.subdividingPreview': 'Preparing preview\u2026',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 Amplitude exceeds 10% of the smallest model dimension \u2014 geometry overlaps may occur in the exported STL.',
// 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 10M-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': 'Simplifying {from} \u2192 {to} triangles\u2026',
'progress.decimating': 'Simplifying: {cur} \u2192 {to} triangles',
'progress.writingStl': 'Writing STL\u2026',
'progress.done': 'Done!',
'progress.processing': 'Processing\u2026',
// License popup
'license.btn': 'License & Terms',
'license.title': 'License & Terms',
'license.item1': 'Free to use for any purpose, including commercial work (e.g., texturing STLs for clients or products).',
'license.item2': 'Attribution is appreciated but not required when using this tool as-is.',
'license.item3': 'Support this tool? Shop at CNCKitchen.STORE or donate on PayPal.',
'license.item4': 'This tool is provided as-is with no warranty of any kind. Use at your own risk.',
'license.item5': 'No support is provided. The author is under no obligation to fix bugs, answer questions, or update this tool. That said, bug reports and feature requests are always welcome at texturizer@cnckitchen.com.',
'license.item6': 'The author shall not be held liable for any damages, data loss, or issues arising from the use of this tool.',
// Sponsor modal
'sponsor.title': 'Thanks for using BumpMesh by CNC Kitchen!',
'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.donate': '\uD83D\uDC99 Donate on PayPal',
'sponsor.dontShow': "Don\u2019t show this again",
'sponsor.closeAndContinue':'Close & Continue',
// Store CTA
'cta.store': 'Support this tool? Shop at CNCKitchen.STORE or donate on PayPal',
'cta.storeDismiss': 'Dismiss',
// Alerts
'alerts.loadFailed': 'Could not load model: {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-, .obj- oder .3mf-Datei hier ablegen oder ',
// Viewport footer
'ui.wireframe': 'Drahtgitter',
'ui.controlsHint': 'Linke Maustaste: Drehen \u00a0·\u00a0 Rechte Maustaste: Verschieben \u00a0·\u00a0 Mausrad: Zoomen',
'ui.meshInfo': '{n} Dreiecke · {mb} MB · {sx} × {sy} × {sz} mm',
// Load model button
'ui.loadStl': 'Modell laden\u2026',
// Displacement map section
'sections.displacementMap': 'Textur',
'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': 'Texturtiefe',
'labels.amplitude': 'Amplitude',
// Seam blend
'labels.seamBlend': 'Nahtglättung \u24d8',
'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.',
'labels.transitionSmoothing': 'Übergangsglättung \u24d8',
'tooltips.transitionSmoothing': 'Breite der Übergangszone an Nahtkanten. Niedrige Werte halten den Übergang nah an der Naht; höhere Werte glätten einen breiteren Bereich.',
'labels.textureSmoothing': 'Texturglättung \u24d8',
'tooltips.textureSmoothing': 'Wendet einen Gaußschen Weichzeichner auf die Verschiebungskarte an. Höhere Werte erzeugen weichere, fließendere Oberflächendetails. 0 = aus.',
'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.',
// Winkelmaskierung
'sections.maskAngles': 'Winkel maskieren \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',
'labels.topFaces': 'Oberseiten',
'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',
'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',
'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.shiftHint': 'Shift gedr\u00fcckt halten zum Radieren',
'labels.type': 'Typ',
'brushType.single': 'Einzeln',
'brushType.circle': 'Kreis',
'labels.size': 'Gr\u00f6\u00dfe',
'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 maskiert',
'excl.faceExcluded': '{n} Fl\u00e4che maskiert',
'excl.facesExcluded': '{n} Fl\u00e4chen maskiert',
'excl.faceSelected': '{n} Fl\u00e4che ausgew\u00e4hlt',
'excl.facesSelected': '{n} Fl\u00e4chen ausgew\u00e4hlt',
'excl.hintExclude': 'Maskierte 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.',
// 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.',
// Displacement preview
'labels.displacementPreview': '3D-Vorschau \u24d8',
'tooltips.displacementPreview': 'Unterteilt das Netz und verschiebt Punkte in Echtzeit, damit die tats\u00e4chliche Tiefe sichtbar wird. GPU-intensiv bei komplexen Modellen.',
// Auf Fl\u00e4che platzieren
'ui.placeOnFace': 'Auf Fl\u00e4che platzieren',
'ui.placeOnFaceTitle': 'Klicken Sie auf eine Fl\u00e4che, um sie nach unten auf das Druckbett auszurichten',
'progress.subdividingPreview': 'Vorschau wird vorbereitet\u2026',
// Amplitude overlap warning
'warnings.amplitudeOverlap': '\u26a0 Amplitude \u00fcberschreitet 10% der kleinsten Modellabmessung \u2014 beim Export k\u00f6nnen Geometrie\u00fcberschneidungen auftreten.',
// Export section
'sections.export': 'Export \u24d8',
'tooltips.export': 'Kleinere Kantenl\u00e4nge = mehr Texturdetails. Die Ausgabe wird dann auf das Dreieckslimit vereinfacht.',
'labels.resolution': 'Aufl\u00f6sung',
'tooltips.resolution': 'Kanten l\u00e4nger als dieser Wert werden beim Export unterteilt',
'labels.outputTriangles': 'Max Dreiecke',
'tooltips.outputTriangles': 'Das Netz wird zuerst vollst\u00e4ndig unterteilt, dann auf diese Anzahl dezimiert',
'warnings.safetyCapHit': '\u26a0 10-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 verfeinert\u2026',
'progress.applyingDisplacement': 'Textur auf {n} Dreiecke anwenden\u2026',
'progress.displacingVertices': 'Punkte werden verschoben\u2026',
'progress.decimatingTo': '{from} \u2192 {to} Dreiecke vereinfachen\u2026',
'progress.decimating': 'Vereinfachen: {cur} \u2192 {to} Dreiecke',
'progress.writingStl': 'STL schreiben\u2026',
'progress.done': 'Fertig!',
'progress.processing': 'Verarbeitung\u2026',
// License popup
'license.btn': 'Lizenz & Nutzung',
'license.title': 'Lizenz & Nutzungsbedingungen',
'license.item1': 'Kostenlos nutzbar f\u00fcr jeden Zweck, auch f\u00fcr kommerzielle Arbeit (z.B. Texturierung von STLs f\u00fcr Kunden oder Produkte).',
'license.item2': 'Namensnennung wird gesch\u00e4tzt, ist aber bei der Nutzung dieses Tools nicht erforderlich.',
'license.item3': 'Dieses Tool unterst\u00fctzen? Shoppe bei CNCKitchen.STORE oder spende via PayPal.',
'license.item4': 'Dieses Tool wird ohne jegliche Gew\u00e4hrleistung bereitgestellt. Nutzung auf eigene Gefahr.',
'license.item5': 'Es wird kein Support geleistet. Der Autor ist nicht verpflichtet, Fehler zu beheben, Fragen zu beantworten oder das Tool zu aktualisieren. Fehlerberichte und Funktionswünsche sind aber jederzeit willkommen unter texturizer@cnckitchen.com.',
'license.item6': 'Der Autor haftet nicht f\u00fcr Sch\u00e4den, Datenverlust oder Probleme, die durch die Nutzung dieses Tools entstehen.',
// Sponsor modal
'sponsor.title': 'Danke für die Nutzung von BumpMesh by CNC Kitchen!',
'sponsor.body': 'Dieses Tool wird von CNC Kitchen komplett kostenlos bereitgestellt. Während dein STL verarbeitet wird, schau doch mal im Shop vorbei, der uns hilft, coole Sachen für dich zu machen!',
'sponsor.visitStore': '\uD83D\uDED2 CNCKitchen.STORE besuchen',
'sponsor.donate': '\uD83D\uDC99 Via PayPal spenden',
'sponsor.dontShow': 'Nicht mehr anzeigen',
'sponsor.closeAndContinue':'Schlie\u00dfen & Weiter',
// Store CTA
'cta.store': 'Dieses Tool unterst\u00fctzen? Shoppe bei CNCKitchen.STORE oder spende via PayPal',
'cta.storeDismiss': 'Ausblenden',
// Alerts
'alerts.loadFailed': 'Modell 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));
});
//