From b8056328266b4e833a9eb1e9c4881fd5dddb377a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 4 Nov 2026 15:45:02 +0100 Subject: [PATCH] =?UTF-8?q?Erg=C3=A4nze=203D-Vorschau=20zur=20individuelle?= =?UTF-8?q?n=20Haar-=20und=20Augenfarbe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/falukant-character-appearance.md | 104 +++++++++ frontend/package.json | 1 + .../scripts/test-character-appearance.mjs | 76 ++++++ frontend/src/components/Character3D.vue | 131 +++++++++-- .../falukant/CharacterAppearanceEditor.vue | 157 +++++++++++++ .../falukant/FalukantCharacterVisual.vue | 2 + frontend/src/utils/characterAppearance.js | 217 ++++++++++++++++++ frontend/src/utils/threeModelRuntime.js | 3 + .../src/views/falukant/AllCharactersTest.vue | 46 ++-- 9 files changed, 695 insertions(+), 42 deletions(-) create mode 100644 frontend/docs/falukant-character-appearance.md create mode 100644 frontend/scripts/test-character-appearance.mjs create mode 100644 frontend/src/components/falukant/CharacterAppearanceEditor.vue create mode 100644 frontend/src/utils/characterAppearance.js 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 @@