diff --git a/frontend/docs/falukant-character-appearance.md b/frontend/docs/falukant-character-appearance.md
new file mode 100644
index 0000000..f0e4ad8
--- /dev/null
+++ b/frontend/docs/falukant-character-appearance.md
@@ -0,0 +1,104 @@
+# Individuelle Augen- und Haarfarben
+
+## Bestehende Modelle vorbereiten
+
+Die vorhandenen Meshy-GLBs enthalten ein gemeinsames Mesh und Material für Haut,
+Kleidung, Haare und Augen. Eine Änderung der allgemeinen Materialfarbe würde
+deshalb die gesamte Figur färben. Die Bereiche müssen einmal pro Modell
+zugeordnet werden; die Geometrie wird dabei nicht anhand von Farbähnlichkeiten
+automatisch geraten.
+
+Unter `/falukant/dev-all-characters` öffnet sich jetzt standardmäßig die
+Vorschau **Farben und Modellbereiche**. Die bisherige Altersübersicht bleibt
+über **Alle Altersmodelle** erreichbar.
+
+1. Geschlecht/Alter wählen oder eine lokale GLB-Datei öffnen. Für die Bearbeitung
+ möglichst die Quelldatei aus `YourPart3Assets/models-src/3d/falukant/characters`
+ statt eines bereits reduzierten Laufzeitmodells verwenden.
+2. In **Drehen / Zoom** das Modell ausrichten. Das Mausrad zoomt, die rechte
+ Maustaste verschiebt die Ansicht.
+3. **Haare markieren** wählen und sichtbare Haarflächen überstreichen. Zum
+ Erreichen weiterer Seiten wieder in den Drehmodus wechseln.
+4. Für **Iris markieren** nahe heranzoomen und einen kleinen Pinsel verwenden.
+ Augenweiß, Pupillen, Lider und Wimpern aussparen.
+5. Mit **Markierung entfernen** Bereiche auf ihre ursprüngliche Textur
+ zurücksetzen. Die letzten zehn Pinselstriche lassen sich rückgängig machen.
+6. Augen-/Haarfarbe ändern und die Zuordnung prüfen. Die Checkbox zeigt zum
+ Vergleich die Originalfarben. Haut und Kleidung werden nicht eingefärbt.
+7. Das bearbeitete GLB herunterladen und erneut öffnen, um die Zuordnung zu
+ kontrollieren. Exportiert werden die Materialbereiche mit ihren ursprünglichen
+ Texturen, nicht die gewählten Vorschaufarben.
+
+Die Originaldateien im Asset-Repository werden vom Editor nicht überschrieben.
+Nach Kontrolle die heruntergeladene Datei unter dem bisherigen Quellnamen im
+Asset-Repository ablegen, z. B. `female_20y.glb`, und das Asset-Repository committen.
+Die bestehende Pipeline `npm run build-runtime-models -- --assets-dir
+../YourPart3Assets --category characters` erstellt daraus die optimierten GLBs.
+Die Exportdatei erhält bewusst keinen `_opt`-Suffix, da solche Dateien vom
+Quellmodell-Build übersprungen werden.
+
+Noch nicht markierte Modelle behalten ihr bisheriges Aussehen. Diese Änderung
+ersetzt nicht automatisch alle bestehenden GLBs. Es gibt keine neue
+Datenbankmigration oder Speicherung pro Spielcharakter.
+
+## Schnittstelle für die Anzeige
+
+`Character3D` und `FalukantCharacterVisual` akzeptieren ein `appearance`-Objekt:
+
+```vue
+
+```
+
+Nur sechsstellige Hexfarben werden angewendet. `null` oder ein fehlender Wert
+zeigt für den jeweiligen Bereich die Originaltextur. Farbänderungen benötigen
+keinen erneuten Download und keine neue Modellinstanz.
+
+Materialien enthalten glTF-`extras` in dieser Form:
+
+```json
+{
+ "falukantAppearance": {
+ "version": 1,
+ "region": "hair",
+ "source": "material-0"
+ }
+}
+```
+
+Bereiche sind `body`, `hair` und `iris`. `source` ordnet die Bereiche dem
+ursprünglichen Material zu. Alternativ werden für extern vorbereitete Modelle
+die exakten Materialnamen `hair`, `iris`, `falukant_hair` und `falukant_iris`
+erkannt. Die Standardmaterialien und Texturkoordinaten bleiben erhalten.
+Der Pinsel teilt Dreiecke in Materialgruppen; die Genauigkeit hängt deshalb
+von der vorhandenen Geometrie ab. Sehr grobe Augenbereiche benötigen eine
+feinere Geometrie oder eine spätere Texturmaskierung.
+
+Die Farbanpassung erhält Helligkeitsdetails und berücksichtigt auch eingebrannte
+Farben in emissiven Texturen. Shader-Uniforms werden pro Material gehalten und
+nicht als Laufzeitobjekte in das GLB geschrieben. Der Export stellt die originale
+Wurzeltransformation wieder her und übernimmt vorhandene Animationen.
+
+## Spätere altersabhängige Attribute
+
+Ergrauen kann später über eine altersabhängige Haarfarbe auf derselben
+Schnittstelle aufsetzen. Für Wachstum oder Veränderungen der Gesichtsform
+braucht es gezielt vorbereitete Morph-Targets oder austauschbare Geometrie.
+Die bisherigen voneinander unabhängigen Altersmodelle werden durch die
+Farbzuordnung nicht automatisch zu einem verformbaren Basismodell.
+
+## Prüfung
+
+```sh
+cd frontend
+npm run test:character-appearance
+npm run build
+```
+
+Zusätzlich wurde ein vorhandenes `female_20y.glb` im Browser geladen, mit Haar-
+und Irisbereichen versehen, exportiert, mit der bestehenden Draco-/Simplify-
+Pipeline optimiert und erneut importiert. Beide Bereiche und ihre glTF-Metadaten
+blieben erhalten. Die vollständige Markierung aller Modellvarianten steht noch aus.
diff --git a/frontend/package.json b/frontend/package.json
index 8aff5d2..25a444f 100755
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,6 +8,7 @@
"preview": "vite preview",
"optimize-models": "node scripts/optimize-glb.mjs",
"build-runtime-models": "node scripts/build-falukant-runtime-models.mjs",
+ "test:character-appearance": "node --test scripts/test-character-appearance.mjs",
"i18n:check-parity": "node scripts/check-i18n-locale-parity.mjs de fr",
"i18n:translate-fr": "node scripts/generate-fr-locale-from-de.mjs",
"i18n:smooth-falukant-fr": "node scripts/smooth-fr-falukant.mjs"
diff --git a/frontend/scripts/test-character-appearance.mjs b/frontend/scripts/test-character-appearance.mjs
new file mode 100644
index 0000000..385d615
--- /dev/null
+++ b/frontend/scripts/test-character-appearance.mjs
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
+import {
+ applyCharacterAppearance, captureAppearanceEditor, getAppearanceRegion,
+ getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor,
+} from '../src/utils/characterAppearance.js';
+
+function fixture(material = new MeshStandardMaterial()) {
+ const model = new Group();
+ const mesh = new Mesh(new BoxGeometry(), material);
+ model.add(mesh);
+ model.updateMatrixWorld(true);
+ return { model, mesh };
+}
+
+test('painting separates material regions while preserving the original mesh and UVs', () => {
+ const { model, mesh } = fixture();
+ const original = mesh.geometry;
+ const originalUV = original.getAttribute('uv').array.slice();
+ prepareAppearanceEditor(model);
+ assert.notEqual(mesh.geometry, original);
+ assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
+ const snapshot = captureAppearanceEditor(model);
+ const indexBuffer = mesh.geometry.index;
+ assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3(.5, 0, 0) }, 0, 'hair', new Vector3(-1, 0, 0)), true);
+ assert.deepEqual(getAppearanceStats(model), { body: 11, hair: 1, iris: 0 });
+ assert.deepEqual(mesh.geometry.getAttribute('uv').array, originalUV);
+ assert.equal(mesh.geometry.index, indexBuffer);
+ assert.equal(original.groups.length, 6);
+ restoreAppearanceEditor(snapshot);
+ assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
+ assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3() }, 1, 'unknown', new Vector3()), false);
+});
+
+test('hair and iris colors remain independent of other instances and can return to the original', () => {
+ const originalMaterial = new MeshStandardMaterial();
+ const first = fixture(originalMaterial);
+ const second = fixture(originalMaterial);
+ prepareAppearanceEditor(first.model);
+ prepareAppearanceEditor(second.model);
+ applyCharacterAppearance(first.model, { hairColor: '#ff0000', eyeColor: '#0000ff' });
+ applyCharacterAppearance(second.model, { hairColor: '#00ff00' });
+ const shader = () => ({ uniforms: {}, fragmentShader: '#include \n#include ' });
+ const hairShader = shader(), irisShader = shader(), otherShader = shader();
+ first.mesh.material[1].onBeforeCompile(hairShader);
+ first.mesh.material[2].onBeforeCompile(irisShader);
+ second.mesh.material[1].onBeforeCompile(otherShader);
+ assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'ff0000');
+ assert.equal(irisShader.uniforms.falukantTargetColor.value.getHexString(), '0000ff');
+ assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
+ applyCharacterAppearance(first.model, { hairColor: '#cccccc', eyeColor: null });
+ assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'cccccc');
+ assert.equal(irisShader.uniforms.falukantColorEnabled.value, 0);
+ assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
+ applyCharacterAppearance(first.model, { hairColor: 'invalid' });
+ assert.equal(hairShader.uniforms.falukantColorEnabled.value, 0);
+});
+
+test('saved glTF material metadata can be prepared again without multiplying materials', () => {
+ const first = fixture();
+ prepareAppearanceEditor(first.model);
+ const model = new Group();
+ const mesh = new Mesh(first.mesh.geometry.clone(), first.mesh.material.map(material => {
+ const copy = material.clone();
+ copy.userData = JSON.parse(JSON.stringify(material.userData));
+ return copy;
+ }));
+ model.add(mesh);
+ prepareAppearanceEditor(model);
+ assert.equal(mesh.material.length, 3);
+ assert.equal(getAppearanceRegion(mesh.material[1]), 'hair');
+ assert.equal(getAppearanceRegion(mesh.material[2]), 'iris');
+ prepareAppearanceEditor(model);
+ assert.equal(mesh.material.length, 3);
+});
diff --git a/frontend/src/components/Character3D.vue b/frontend/src/components/Character3D.vue
index be4ec6d..3c46e4e 100755
--- a/frontend/src/components/Character3D.vue
+++ b/frontend/src/components/Character3D.vue
@@ -1,6 +1,8 @@