Ergänze 3D-Vorschau zur individuellen Haar- und Augenfarbe
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s
This commit is contained in:
104
frontend/docs/falukant-character-appearance.md
Normal file
104
frontend/docs/falukant-character-appearance.md
Normal 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.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"optimize-models": "node scripts/optimize-glb.mjs",
|
"optimize-models": "node scripts/optimize-glb.mjs",
|
||||||
"build-runtime-models": "node scripts/build-falukant-runtime-models.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:check-parity": "node scripts/check-i18n-locale-parity.mjs de fr",
|
||||||
"i18n:translate-fr": "node scripts/generate-fr-locale-from-de.mjs",
|
"i18n:translate-fr": "node scripts/generate-fr-locale-from-de.mjs",
|
||||||
"i18n:smooth-falukant-fr": "node scripts/smooth-fr-falukant.mjs"
|
"i18n:smooth-falukant-fr": "node scripts/smooth-fr-falukant.mjs"
|
||||||
|
|||||||
76
frontend/scripts/test-character-appearance.mjs
Normal file
76
frontend/scripts/test-character-appearance.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="character-3d-shell">
|
<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
|
<img
|
||||||
v-if="showFallback"
|
v-if="showFallback"
|
||||||
class="character-fallback"
|
class="character-fallback"
|
||||||
@@ -30,7 +32,7 @@ const OPTIMIZED_MODEL_SUFFIX = '_opt.glb';
|
|||||||
// Bump this when deployment replaces GLB files under unchanged filenames.
|
// 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
|
// The revision is part of the URL, so a browser cannot reuse an older immutable
|
||||||
// response for the same model file.
|
// 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
|
// 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.
|
// the nearest lower one instead of the generic gender/age-group fallback.
|
||||||
const AVAILABLE_MODEL_AGES = Object.freeze({
|
const AVAILABLE_MODEL_AGES = Object.freeze({
|
||||||
@@ -98,7 +100,13 @@ async function loadThreeModelRuntime() {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Character3D',
|
name: 'Character3D',
|
||||||
|
emits: ['model-loaded', 'model-error', 'appearance-changed'],
|
||||||
props: {
|
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: {
|
gender: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -130,6 +138,12 @@ export default {
|
|||||||
camera: null,
|
camera: null,
|
||||||
renderer: null,
|
renderer: null,
|
||||||
model: null,
|
model: null,
|
||||||
|
modelLoadVersion: 0,
|
||||||
|
originalTransform: null,
|
||||||
|
sourceAnimations: [],
|
||||||
|
undoHistory: [],
|
||||||
|
painting: false,
|
||||||
|
lastPaintAt: 0,
|
||||||
animationId: null,
|
animationId: null,
|
||||||
mixer: null,
|
mixer: null,
|
||||||
clock: null,
|
clock: null,
|
||||||
@@ -205,6 +219,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
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() {
|
async actualGender() {
|
||||||
if (this.is3DDisabled) return;
|
if (this.is3DDisabled) return;
|
||||||
await this.loadModel();
|
await this.loadModel();
|
||||||
@@ -421,23 +447,16 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.scene) return;
|
if (!this.scene) return;
|
||||||
|
const loadVersion = ++this.modelLoadVersion;
|
||||||
const modelRuntime = await this.ensureThreeModelRuntime();
|
const modelRuntime = await this.ensureThreeModelRuntime();
|
||||||
const loaders = await this.ensureThreeLoaders();
|
const loaders = await this.ensureThreeLoaders();
|
||||||
|
if (loadVersion !== this.modelLoadVersion || !this.scene) return;
|
||||||
|
|
||||||
// Altes Modell entfernen
|
// Altes Modell entfernen
|
||||||
if (this.model) {
|
if (this.model) {
|
||||||
this.scene.remove(this.model);
|
this.scene.remove(this.model);
|
||||||
// Cleanup des alten Modells
|
// Cleanup des alten Modells
|
||||||
this.model.traverse((object) => {
|
modelRuntime.disposeCharacterModel(this.model);
|
||||||
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.model = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +478,7 @@ export default {
|
|||||||
const candidateAges = availableAges
|
const candidateAges = availableAges
|
||||||
.filter((age) => age <= modelAge)
|
.filter((age) => age <= modelAge)
|
||||||
.sort((left, right) => right - left);
|
.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}`
|
`${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
|
// 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.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)
|
// Initiale Bounding Box für Größenberechnung (vor Skalierung)
|
||||||
const initialBox = new modelRuntime.Box3().setFromObject(this.model);
|
const initialBox = new modelRuntime.Box3().setFromObject(this.model);
|
||||||
@@ -602,12 +640,67 @@ export default {
|
|||||||
if (this.model) {
|
if (this.model) {
|
||||||
this.model.rotation.y = 0;
|
this.model.rotation.y = 0;
|
||||||
}
|
}
|
||||||
|
this.showFallback = false;
|
||||||
|
this.$emit('model-loaded', modelRuntime.getAppearanceStats(this.model));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (loadVersion !== this.modelLoadVersion) return;
|
||||||
console.error('Error loading 3D model:', error);
|
console.error('Error loading 3D model:', error);
|
||||||
this.showFallback = true;
|
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() {
|
animate() {
|
||||||
this.animationId = requestAnimationFrame(this.animate);
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
@@ -644,6 +737,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
this.modelLoadVersion++;
|
||||||
if (this.animationId) {
|
if (this.animationId) {
|
||||||
cancelAnimationFrame(this.animationId);
|
cancelAnimationFrame(this.animationId);
|
||||||
}
|
}
|
||||||
@@ -655,16 +749,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.model) {
|
if (this.model) {
|
||||||
this.model.traverse((object) => {
|
this.threeModelRuntime?.disposeCharacterModel(this.model);
|
||||||
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.model = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
157
frontend/src/components/falukant/CharacterAppearanceEditor.vue
Normal file
157
frontend/src/components/falukant/CharacterAppearanceEditor.vue
Normal 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>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<Character3D
|
<Character3D
|
||||||
:gender="normalizedGender"
|
:gender="normalizedGender"
|
||||||
:age="normalizedAge"
|
:age="normalizedAge"
|
||||||
|
:appearance="appearance"
|
||||||
:lightweight="lightweight"
|
:lightweight="lightweight"
|
||||||
:noBackground="noBackground"
|
:noBackground="noBackground"
|
||||||
:lazy="lazy"
|
:lazy="lazy"
|
||||||
@@ -70,6 +71,7 @@ export default {
|
|||||||
Character3D,
|
Character3D,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
appearance: { type: Object, default: () => ({}) },
|
||||||
gender: {
|
gender: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'male',
|
default: 'male',
|
||||||
|
|||||||
217
frontend/src/utils/characterAppearance.js
Normal file
217
frontend/src/utils/characterAppearance.js
Normal 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?.());
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { AnimationMixer } from 'three/src/animation/AnimationMixer.js';
|
import { AnimationMixer } from 'three/src/animation/AnimationMixer.js';
|
||||||
import { Box3 } from 'three/src/math/Box3.js';
|
import { Box3 } from 'three/src/math/Box3.js';
|
||||||
import { Vector3 } from 'three/src/math/Vector3.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 {
|
export {
|
||||||
AnimationMixer,
|
AnimationMixer,
|
||||||
|
|||||||
@@ -1,41 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="all-characters-test">
|
<div class="all-characters-test">
|
||||||
<h1>All 3D Characters (Ages × Genders)</h1>
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label><input type="checkbox" v-model="useLightweight"> Use lightweight models</label>
|
<button type="button" :aria-pressed="editorMode" @click="editorMode = true">Farben und Modellbereiche</button>
|
||||||
<label><input type="checkbox" v-model="noBackground"> No background</label>
|
<button type="button" :aria-pressed="!editorMode" @click="editorMode = false">Alle Altersmodelle</button>
|
||||||
</div>
|
</div>
|
||||||
|
<CharacterAppearanceEditor v-if="editorMode" />
|
||||||
<div class="grid">
|
<template v-else>
|
||||||
<div v-for="(age, idx) in ages" :key="`age-${age}`" class="card">
|
<h1>All 3D Characters (Ages × Genders)</h1>
|
||||||
<div class="label">Alter: {{ age }}</div>
|
<div class="controls">
|
||||||
<div class="row">
|
<label><input type="checkbox" v-model="useLightweight"> Use lightweight models</label>
|
||||||
<div class="cell">
|
<label><input type="checkbox" v-model="noBackground"> No background</label>
|
||||||
<div class="mini-shell">
|
</div>
|
||||||
<Character3D :gender="'female'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
|
<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>
|
||||||
<div class="sub">weiblich</div>
|
<div class="cell">
|
||||||
</div>
|
<div class="mini-shell">
|
||||||
<div class="cell">
|
<Character3D :gender="'male'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
|
||||||
<div class="mini-shell">
|
</div>
|
||||||
<Character3D :gender="'male'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
|
<div class="sub">männlich</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub">männlich</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Character3D from '@/components/Character3D.vue';
|
import Character3D from '@/components/Character3D.vue';
|
||||||
|
import CharacterAppearanceEditor from '@/components/falukant/CharacterAppearanceEditor.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AllCharactersTest',
|
name: 'AllCharactersTest',
|
||||||
components: { Character3D },
|
components: { Character3D, CharacterAppearanceEditor },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
editorMode: true,
|
||||||
// Show ages 0..17 and some representative adults
|
// Show ages 0..17 and some representative adults
|
||||||
ages: [...Array(18).keys()].concat([25, 40, 70]),
|
ages: [...Array(18).keys()].concat([25, 40, 70]),
|
||||||
useLightweight: true,
|
useLightweight: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user