Ergänze 3D-Vorschau zur individuellen Haar- und Augenfarbe
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 15:45:02 +01:00
parent 6e31a114cf
commit b805632826
9 changed files with 695 additions and 42 deletions

View File

@@ -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
<Character3D
gender="female"
:age="20"
:appearance="{ hairColor: '#6b4028', eyeColor: '#438ca7' }"
/>
```
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.

View File

@@ -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"

View File

@@ -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 <map_fragment>\n#include <emissivemap_fragment>' });
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);
});

View File

@@ -1,6 +1,8 @@
<template>
<div class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container"></div>
<div v-show="!showFallback" ref="container" class="character-3d-container"
@pointerdown.capture="startPainting" @pointermove="continuePainting"
@pointerup="finishPainting" @pointercancel="finishPainting"></div>
<img
v-if="showFallback"
class="character-fallback"
@@ -30,7 +32,7 @@ const OPTIMIZED_MODEL_SUFFIX = '_opt.glb';
// Bump this when deployment replaces GLB files under unchanged filenames.
// The revision is part of the URL, so a browser cannot reuse an older immutable
// response for the same model file.
const MODEL_ASSET_REVISION = '20260930-2';
const MODEL_ASSET_REVISION = '20261104-appearance-1';
// Only concrete age models are used. If an exact age is not available, choose
// the nearest lower one instead of the generic gender/age-group fallback.
const AVAILABLE_MODEL_AGES = Object.freeze({
@@ -98,7 +100,13 @@ async function loadThreeModelRuntime() {
export default {
name: 'Character3D',
emits: ['model-loaded', 'model-error', 'appearance-changed'],
props: {
modelSrc: { type: String, default: null },
appearance: { type: Object, default: () => ({}) },
editable: { type: Boolean, default: false },
paintRegion: { type: String, default: null },
brushSize: { type: Number, default: 0.015 },
gender: {
type: String,
default: null,
@@ -130,6 +138,12 @@ export default {
camera: null,
renderer: null,
model: null,
modelLoadVersion: 0,
originalTransform: null,
sourceAnimations: [],
undoHistory: [],
painting: false,
lastPaintAt: 0,
animationId: null,
mixer: null,
clock: null,
@@ -205,6 +219,18 @@ export default {
}
},
watch: {
appearance: {
deep: true,
handler() {
if (this.model && this.threeModelRuntime) this.threeModelRuntime.applyCharacterAppearance(this.model, this.appearance);
}
},
paintRegion(value) {
if (this.controls) this.controls.enabled = !value;
},
async modelSrc() {
if (!this.is3DDisabled) await this.loadModel();
},
async actualGender() {
if (this.is3DDisabled) return;
await this.loadModel();
@@ -421,23 +447,16 @@ export default {
}
if (!this.scene) return;
const loadVersion = ++this.modelLoadVersion;
const modelRuntime = await this.ensureThreeModelRuntime();
const loaders = await this.ensureThreeLoaders();
if (loadVersion !== this.modelLoadVersion || !this.scene) return;
// Altes Modell entfernen
if (this.model) {
this.scene.remove(this.model);
// Cleanup des alten Modells
this.model.traverse((object) => {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose());
} else {
object.material.dispose();
}
}
});
modelRuntime.disposeCharacterModel(this.model);
this.model = null;
}
@@ -459,7 +478,7 @@ export default {
const candidateAges = availableAges
.filter((age) => age <= modelAge)
.sort((left, right) => right - left);
const candidates = candidateAges.map((age) =>
const candidates = this.modelSrc ? [this.modelSrc] : candidateAges.map((age) =>
`${prefix}/${this.actualGender}_${age}y${OPTIMIZED_MODEL_SUFFIX}?v=${MODEL_ASSET_REVISION}`
);
@@ -491,7 +510,26 @@ export default {
}
// Modell als raw markieren, um Vue's Reactivity zu vermeiden
if (loadVersion !== this.modelLoadVersion || !this.scene) {
modelRuntime.disposeCharacterModel(gltf.scene);
return;
}
this.model = markRaw(gltf.scene);
this.originalTransform = markRaw({
position: this.model.position.clone(),
quaternion: this.model.quaternion.clone(),
scale: this.model.scale.clone(),
});
this.sourceAnimations = markRaw(gltf.animations || []);
this.undoHistory = [];
this.painting = false;
if (this.editable) modelRuntime.prepareAppearanceEditor(this.model);
modelRuntime.applyCharacterAppearance(this.model, this.appearance);
if (this.controls) {
this.controls.enabled = !this.paintRegion;
this.controls.minDistance = this.editable ? 0.1 : 1;
this.controls.enablePan = this.editable;
}
// Initiale Bounding Box für Größenberechnung (vor Skalierung)
const initialBox = new modelRuntime.Box3().setFromObject(this.model);
@@ -602,12 +640,67 @@ export default {
if (this.model) {
this.model.rotation.y = 0;
}
this.showFallback = false;
this.$emit('model-loaded', modelRuntime.getAppearanceStats(this.model));
} catch (error) {
if (loadVersion !== this.modelLoadVersion) return;
console.error('Error loading 3D model:', error);
this.showFallback = true;
this.$emit('model-error', error.message || 'Modell konnte nicht geladen werden.');
}
},
startPainting(event) {
if (!this.editable || !this.paintRegion || !this.model || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
this.undoHistory.push(markRaw(this.threeModelRuntime.captureAppearanceEditor(this.model)));
if (this.undoHistory.length > 10) this.undoHistory.shift();
this.painting = true;
this.$refs.container.setPointerCapture(event.pointerId);
this.paintAtEvent(event);
},
continuePainting(event) {
if (!this.painting || performance.now() - this.lastPaintAt < 50) return;
this.paintAtEvent(event);
},
finishPainting(event) {
this.painting = false;
if (this.$refs.container?.hasPointerCapture(event.pointerId)) this.$refs.container.releasePointerCapture(event.pointerId);
},
paintAtEvent(event) {
const runtime = this.threeModelRuntime;
if (!runtime || !this.model || !this.renderer) return;
this.lastPaintAt = performance.now();
const rect = this.renderer.domElement.getBoundingClientRect();
const pointer = new runtime.Vector2((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
const raycaster = new runtime.Raycaster();
raycaster.setFromCamera(pointer, this.camera);
const hit = raycaster.intersectObject(this.model, true)[0];
if (!hit) return;
const height = new runtime.Box3().setFromObject(this.model).getSize(new runtime.Vector3()).y;
if (runtime.paintAppearanceRegion(hit, Math.max(0.001, this.brushSize) * height, this.paintRegion, raycaster.ray.direction)) {
runtime.applyCharacterAppearance(this.model, this.appearance);
this.$emit('appearance-changed', runtime.getAppearanceStats(this.model));
}
},
undoAppearance() {
const snapshot = this.undoHistory.pop();
if (!snapshot || !this.model) return;
this.threeModelRuntime.restoreAppearanceEditor(snapshot);
this.$emit('appearance-changed', this.threeModelRuntime.getAppearanceStats(this.model));
},
async exportAppearanceModel() {
if (!this.model || !this.editable) throw new Error('Bitte zuerst ein Modell laden.');
const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
const root = this.model.clone(true);
root.position.copy(this.originalTransform.position);
root.quaternion.copy(this.originalTransform.quaternion);
root.scale.copy(this.originalTransform.scale);
const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: this.sourceAnimations });
return new Blob([binary], { type: 'model/gltf-binary' });
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
@@ -644,6 +737,7 @@ export default {
},
cleanup() {
this.modelLoadVersion++;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
@@ -655,16 +749,7 @@ export default {
}
if (this.model) {
this.model.traverse((object) => {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose());
} else {
object.material.dispose();
}
}
});
this.threeModelRuntime?.disposeCharacterModel(this.model);
this.model = null;
}

View File

@@ -0,0 +1,157 @@
<template>
<section class="appearance-editor">
<div class="appearance-editor__controls">
<h2>Individuelles Aussehen</h2>
<p>Modell auswählen oder eine GLB-Datei öffnen. Haare und Iris einmal markieren, danach die Farben frei ändern.</p>
<div class="appearance-editor__pair">
<label>Modell
<select v-model="gender" @change="useBuiltInModel">
<option value="female">Weiblich</option><option value="male">Männlich</option>
</select>
</label>
<label>Alter
<select v-model.number="age" @change="useBuiltInModel">
<option v-for="value in ages" :key="value" :value="value">{{ value }} Jahre</option>
</select>
</label>
</div>
<label class="appearance-editor__file">GLB-Datei öffnen
<input type="file" accept=".glb,model/gltf-binary" @change="openFile" />
</label>
<p v-if="fileName" class="appearance-editor__note">{{ fileName }}</p>
<fieldset>
<legend>Bereiche zuordnen</legend>
<div class="appearance-editor__tools">
<button v-for="tool in tools" :key="tool.value" type="button"
:aria-pressed="mode === tool.value" :class="{ active: mode === tool.value }"
@click="mode = tool.value">{{ tool.label }}</button>
</div>
<p class="appearance-editor__note">{{ toolHint }}</p>
<label v-if="mode !== 'rotate'">Pinselgröße
<input v-model.number="brushSize" type="range" min="0.002" max="0.06" step="0.001" />
</label>
<button type="button" :disabled="!loaded" @click="$refs.character?.undoAppearance()">Markierung rückgängig</button>
</fieldset>
<fieldset>
<legend>Farben ausprobieren</legend>
<div class="appearance-editor__pair">
<label>Haarfarbe <input v-model="hairColor" type="color" /></label>
<label>Augenfarbe <input v-model="eyeColor" type="color" /></label>
</div>
<label class="appearance-editor__checkbox"><input v-model="showColors" type="checkbox" /> Farbänderungen anzeigen</label>
</fieldset>
<p class="appearance-editor__note">Haare: {{ stats.hair ? 'markiert' : 'noch nicht markiert' }} · Iris: {{ stats.iris ? 'markiert' : 'noch nicht markiert' }}</p>
<button type="button" :disabled="!loaded || exporting" @click="downloadModel">
{{ exporting ? 'GLB wird erstellt …' : 'Bearbeitetes GLB herunterladen' }}
</button>
<p class="appearance-editor__note">Gespeichert werden die markierten Bereiche. Die Vorschaufarben lassen sich später pro Charakter einstellen.</p>
<p v-if="error" role="alert" class="appearance-editor__error">{{ error }}</p>
<p v-else role="status" class="appearance-editor__note">{{ status }}</p>
</div>
<div class="appearance-editor__stage" :class="{ 'is-painting': mode !== 'rotate' }">
<Character3D :key="modelKey" ref="character" :gender="gender" :age="age"
:model-src="localSource" :appearance="appearance" editable :paint-region="paintRegion"
:brush-size="brushSize" no-background
@model-loaded="modelLoaded" @appearance-changed="stats = $event"
@model-error="modelError" />
</div>
</section>
</template>
<script>
import Character3D from '@/components/Character3D.vue';
export default {
name: 'CharacterAppearanceEditor',
components: { Character3D },
data() {
return {
gender: 'female', age: 20,
ages: [...Array(17).keys(), 18, 20, 25, 30, 40, 50, 60],
localSource: null, fileName: '', mode: 'rotate', brushSize: 0.015,
hairColor: '#69432d', eyeColor: '#3d92b5', showColors: true,
loaded: false, exporting: false, status: 'Modell wird geladen …', error: '',
stats: { body: 0, hair: 0, iris: 0 },
tools: [
{ value: 'rotate', label: 'Drehen / Zoom' },
{ value: 'hair', label: 'Haare markieren' },
{ value: 'iris', label: 'Iris markieren' },
{ value: 'body', label: 'Markierung entfernen' },
],
};
},
computed: {
appearance() { return { hairColor: this.showColors ? this.hairColor : null, eyeColor: this.showColors ? this.eyeColor : null }; },
modelKey() { return this.localSource || `${this.gender}-${this.age}`; },
paintRegion() { return this.mode === 'rotate' ? null : this.mode; },
toolHint() {
if (this.mode === 'rotate') return 'Ziehen zum Drehen, Mausrad zum Zoomen, rechte Maustaste zum Verschieben.';
if (this.mode === 'iris') return 'Nah heranzoomen und nur die farbige Iris markieren. Pupille und Augenweiß aussparen.';
if (this.mode === 'body') return 'Über eine Fläche streichen, um dort die ursprüngliche Textur wiederherzustellen.';
return 'Über die sichtbaren Haarflächen streichen. Für Rückseite und Seiten wieder auf Drehen wechseln.';
},
},
beforeUnmount() { if (this.localSource) URL.revokeObjectURL(this.localSource); },
methods: {
resetStatus() {
this.loaded = false;
this.error = '';
this.status = 'Modell wird geladen …';
this.stats = { body: 0, hair: 0, iris: 0 };
},
useBuiltInModel() {
if (this.localSource) URL.revokeObjectURL(this.localSource);
this.localSource = null;
this.fileName = '';
this.resetStatus();
},
openFile(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!/\.glb$/i.test(file.name)) { this.error = 'Bitte eine GLB-Datei auswählen.'; return; }
if (this.localSource) URL.revokeObjectURL(this.localSource);
this.localSource = URL.createObjectURL(file);
this.fileName = file.name;
this.resetStatus();
event.target.value = '';
},
modelLoaded(stats) { this.loaded = true; this.stats = stats; this.status = 'Modell bereit. Farben wirken auf markierte Bereiche.'; },
modelError(message) { this.loaded = false; this.error = message; this.status = ''; },
async downloadModel() {
this.exporting = true;
this.error = '';
try {
const blob = await this.$refs.character.exportAppearanceModel();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = (this.fileName || `${this.gender}_${this.age}y.glb`).replace(/_opt\.glb$/i, '.glb');
link.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
this.status = 'GLB exportiert. Zum Prüfen die heruntergeladene Datei erneut öffnen.';
} catch (error) { this.error = error.message || 'Export fehlgeschlagen.'; }
finally { this.exporting = false; }
},
},
};
</script>
<style scoped>
.appearance-editor { display: grid; grid-template-columns: minmax(250px, 340px) minmax(0, 1fr); gap: 24px; }
.appearance-editor__controls { display: flex; flex-direction: column; align-items: stretch; gap: 14px; }
.appearance-editor__controls h2, .appearance-editor__controls p { margin: 0; }
.appearance-editor__controls label { display: flex; flex-direction: column; gap: 6px; }
.appearance-editor__pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.appearance-editor__pair input[type=color] { width: 100%; min-height: 42px; padding: 3px; }
.appearance-editor__controls fieldset { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--color-border, #ccc); border-radius: 8px; }
.appearance-editor__tools { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.appearance-editor__tools button.active { outline: 2px solid var(--color-primary, #39844f); outline-offset: 1px; }
.appearance-editor__controls .appearance-editor__checkbox { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.appearance-editor__note { font-size: 0.875rem; line-height: 1.5; color: var(--color-text-secondary, #5a655d); overflow-wrap: anywhere; }
.appearance-editor__error { color: var(--color-danger, #a63030); }
.appearance-editor__stage { height: 720px; min-height: 500px; border-radius: 12px; border: 1px solid var(--color-border, #ccc); background: #e6e4de; overflow: hidden; position: sticky; top: 12px; }
.appearance-editor__stage :deep(.character-3d-shell), .appearance-editor__stage :deep(.character-3d-container) { width: 100%; height: 100%; }
.appearance-editor__stage.is-painting :deep(canvas) { cursor: crosshair; }
.appearance-editor__stage :deep(.character-debug-overlay) { display: none; }
@media (max-width: 760px) { .appearance-editor { grid-template-columns: 1fr; } .appearance-editor__stage { grid-row: 1; position: static; height: 480px; min-height: 0; } }
</style>

View File

@@ -10,6 +10,7 @@
<Character3D
:gender="normalizedGender"
:age="normalizedAge"
:appearance="appearance"
:lightweight="lightweight"
:noBackground="noBackground"
:lazy="lazy"
@@ -70,6 +71,7 @@ export default {
Character3D,
},
props: {
appearance: { type: Object, default: () => ({}) },
gender: {
type: String,
default: 'male',

View File

@@ -0,0 +1,217 @@
import { Color, Vector3 } from 'three';
export const APPEARANCE_REGIONS = Object.freeze(['body', 'hair', 'iris']);
const editorStates = new WeakMap();
const colorStates = new WeakMap();
export function getAppearanceRegion(material) {
const region = material?.userData?.falukantAppearance?.region;
if (APPEARANCE_REGIONS.includes(region)) return region;
const name = String(material?.name || '').toLowerCase();
if (['hair', 'falukant_hair'].includes(name)) return 'hair';
if (['iris', 'falukant_iris'].includes(name)) return 'iris';
return 'body';
}
function setupColor(material) {
if (colorStates.has(material)) return colorStates.get(material);
const uniforms = { target: { value: new Color() }, enabled: { value: 0 } };
const previousCompile = material.onBeforeCompile;
material.onBeforeCompile = function (shader, renderer) {
previousCompile.call(this, shader, renderer);
shader.uniforms.falukantTargetColor = uniforms.target;
shader.uniforms.falukantColorEnabled = uniforms.enabled;
shader.fragmentShader = `uniform vec3 falukantTargetColor;
uniform float falukantColorEnabled;\n${shader.fragmentShader}`;
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', `
#include <map_fragment>
float falukantDetail = clamp(dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)) * 1.8 + 0.18, 0.18, 1.0);
diffuseColor.rgb = mix(diffuseColor.rgb, falukantTargetColor * falukantDetail, falukantColorEnabled);
`);
// Some Meshy models also bake the original color into an emissive map.
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#include <emissivemap_fragment>
float falukantEmission = dot(totalEmissiveRadiance, vec3(0.2126, 0.7152, 0.0722));
totalEmissiveRadiance = mix(totalEmissiveRadiance, falukantTargetColor * falukantEmission, falukantColorEnabled);
`);
};
material.customProgramCacheKey = () => 'falukant-appearance-v1';
material.needsUpdate = true;
colorStates.set(material, uniforms);
return uniforms;
}
export function applyCharacterAppearance(model, appearance = {}) {
model.traverse(object => {
if (!object.isMesh) return;
for (const material of [].concat(object.material || [])) {
const region = getAppearanceRegion(material);
if (region === 'body') continue;
const color = region === 'hair' ? appearance.hairColor : appearance.eyeColor;
const state = setupColor(material);
const valid = typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color);
state.enabled.value = valid ? 1 : 0;
if (valid) state.target.value.set(color);
}
});
}
// Keep texture references, UVs, normals and skinning attributes. Only split
// triangle indices into standard glTF material groups. No texture guessing.
export function prepareAppearanceEditor(model) {
model.traverse(mesh => {
if (!mesh.isMesh || editorStates.has(mesh)) return;
const original = mesh.geometry;
if (!original.getAttribute('position')) return;
mesh.geometry = original.clone();
const geometry = mesh.geometry;
if (!geometry.index) {
geometry.setIndex(Array.from({ length: geometry.getAttribute('position').count }, (_, i) => i));
}
const oldMaterials = [].concat(mesh.material);
const sources = new Map();
const materialSources = [];
const materials = [];
oldMaterials.forEach((material, oldIndex) => {
const key = material.userData?.falukantAppearance?.source ?? `material-${oldIndex}`;
if (!sources.has(key)) {
sources.set(key, sources.size);
for (const region of APPEARANCE_REGIONS) {
const clone = material.clone();
clone.name = `Falukant_${region}_${sources.get(key)}`;
clone.userData.falukantAppearance = { version: 1, region, source: key };
materials.push(clone);
}
}
materialSources[oldIndex] = sources.get(key);
});
const count = geometry.index.count / 3;
const regions = new Uint8Array(count);
const bases = new Uint16Array(count);
const groups = geometry.groups.length ? geometry.groups : [{ start: 0, count: geometry.index.count, materialIndex: 0 }];
for (const group of groups) {
const materialIndex = group.materialIndex || 0;
const region = APPEARANCE_REGIONS.indexOf(getAppearanceRegion(oldMaterials[materialIndex]));
for (let face = group.start / 3; face < (group.start + group.count) / 3; face++) {
regions[face] = region;
bases[face] = materialSources[materialIndex];
}
}
mesh.material = materials;
const state = { regions, bases };
editorStates.set(mesh, state);
rebuildGroups(mesh, state);
});
model.userData.falukantAppearance = { version: 1, regions: APPEARANCE_REGIONS.slice() };
}
function rebuildGroups(mesh, state) {
const geometry = mesh.geometry;
const oldIndex = geometry.index.array;
const buckets = Array.from({ length: mesh.material.length }, () => []);
for (let face = 0; face < state.regions.length; face++) {
buckets[state.bases[face] * 3 + state.regions[face]].push(face);
}
const index = new oldIndex.constructor(oldIndex.length);
const regions = new Uint8Array(state.regions.length);
const bases = new Uint16Array(state.bases.length);
let offset = 0;
geometry.clearGroups();
buckets.forEach((faces, materialIndex) => {
if (!faces.length) return;
geometry.addGroup(offset * 3, faces.length * 3, materialIndex);
for (const face of faces) {
index.set(oldIndex.subarray(face * 3, face * 3 + 3), offset * 3);
regions[offset] = state.regions[face];
bases[offset] = state.bases[face];
offset++;
}
});
state.regions = regions;
state.bases = bases;
// Reuse the GPU index buffer while painting instead of leaking a new one
// for each brush movement.
geometry.index.array.set(index);
geometry.index.needsUpdate = true;
}
export function paintAppearanceRegion(hit, worldRadius, region, cameraDirection) {
const mesh = hit.object;
const state = editorStates.get(mesh);
const label = APPEARANCE_REGIONS.indexOf(region);
if (!state || label < 0 || !Number.isInteger(hit.faceIndex)) return false;
const point = mesh.worldToLocal(hit.point.clone());
const scale = mesh.getWorldScale(new Vector3());
const radius = worldRadius / Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z));
const direction = cameraDirection.clone().transformDirection(mesh.matrixWorld.clone().invert());
const positions = mesh.geometry.getAttribute('position');
const index = mesh.geometry.index.array;
const a = new Vector3(), b = new Vector3(), c = new Vector3();
const normal = new Vector3(), edge = new Vector3(), center = new Vector3();
let changed = false;
for (let face = 0; face < state.regions.length; face++) {
if (state.regions[face] === label) continue;
a.fromBufferAttribute(positions, index[face * 3]);
b.fromBufferAttribute(positions, index[face * 3 + 1]);
c.fromBufferAttribute(positions, index[face * 3 + 2]);
center.copy(a).add(b).add(c).multiplyScalar(1 / 3);
normal.subVectors(b, a).cross(edge.subVectors(c, a));
if (face === hit.faceIndex || (center.distanceToSquared(point) <= radius * radius && normal.dot(direction) < 0)) {
state.regions[face] = label;
changed = true;
}
}
if (changed) rebuildGroups(mesh, state);
return changed;
}
export function captureAppearanceEditor(model) {
const snapshot = [];
model.traverse(mesh => {
const state = editorStates.get(mesh);
if (state) snapshot.push({ mesh, index: mesh.geometry.index.clone(), regions: state.regions.slice(), bases: state.bases.slice() });
});
return snapshot;
}
export function restoreAppearanceEditor(snapshot) {
for (const { mesh, index, regions, bases } of snapshot) {
mesh.geometry.index.array.set(index.array);
mesh.geometry.index.needsUpdate = true;
const state = { regions: regions.slice(), bases: bases.slice() };
editorStates.set(mesh, state);
rebuildGroups(mesh, state);
}
}
export function getAppearanceStats(model) {
const stats = { hair: 0, iris: 0, body: 0 };
model.traverse(mesh => {
if (!mesh.isMesh) return;
const materials = [].concat(mesh.material);
for (const group of mesh.geometry.groups) {
stats[getAppearanceRegion(materials[group.materialIndex || 0])] += group.count / 3;
}
});
return stats;
}
export function disposeCharacterModel(model) {
const geometries = new Set(), materials = new Set(), textures = new Set(), images = new Set();
model.traverse(object => {
if (object.geometry) geometries.add(object.geometry);
for (const material of [].concat(object.material || [])) materials.add(material);
});
for (const material of materials) {
for (const value of Object.values(material)) {
if (value?.isTexture) {
textures.add(value);
if (value.source?.data) images.add(value.source.data);
}
}
material.dispose();
}
geometries.forEach(geometry => geometry.dispose());
textures.forEach(texture => texture.dispose());
images.forEach(image => image.close?.());
}

View File

@@ -1,6 +1,9 @@
import { AnimationMixer } from 'three/src/animation/AnimationMixer.js';
import { Box3 } from 'three/src/math/Box3.js';
import { Vector3 } from 'three/src/math/Vector3.js';
export { Raycaster } from 'three/src/core/Raycaster.js';
export { Vector2 } from 'three/src/math/Vector2.js';
export * from './characterAppearance.js';
export {
AnimationMixer,

View File

@@ -1,41 +1,49 @@
<template>
<div class="all-characters-test">
<h1>All 3D Characters (Ages × Genders)</h1>
<div class="controls">
<label><input type="checkbox" v-model="useLightweight"> Use lightweight models</label>
<label><input type="checkbox" v-model="noBackground"> No background</label>
<button type="button" :aria-pressed="editorMode" @click="editorMode = true">Farben und Modellbereiche</button>
<button type="button" :aria-pressed="!editorMode" @click="editorMode = false">Alle Altersmodelle</button>
</div>
<div class="grid">
<div v-for="(age, idx) in ages" :key="`age-${age}`" class="card">
<div class="label">Alter: {{ age }}</div>
<div class="row">
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'female'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
<CharacterAppearanceEditor v-if="editorMode" />
<template v-else>
<h1>All 3D Characters (Ages × Genders)</h1>
<div class="controls">
<label><input type="checkbox" v-model="useLightweight"> Use lightweight models</label>
<label><input type="checkbox" v-model="noBackground"> No background</label>
</div>
<div class="grid">
<div v-for="(age, idx) in ages" :key="`age-${age}`" class="card">
<div class="label">Alter: {{ age }}</div>
<div class="row">
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'female'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
</div>
<div class="sub">weiblich</div>
</div>
<div class="sub">weiblich</div>
</div>
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'male'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'male'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
</div>
<div class="sub">männlich</div>
</div>
<div class="sub">männlich</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script>
import Character3D from '@/components/Character3D.vue';
import CharacterAppearanceEditor from '@/components/falukant/CharacterAppearanceEditor.vue';
export default {
name: 'AllCharactersTest',
components: { Character3D },
components: { Character3D, CharacterAppearanceEditor },
data() {
return {
editorMode: true,
// Show ages 0..17 and some representative adults
ages: [...Array(18).keys()].concat([25, 40, 70]),
useLightweight: true,