159 lines
8.6 KiB
Vue
159 lines
8.6 KiB
Vue
<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.0005" max="0.06" step="0.0005" />
|
|
</label>
|
|
<button type="button" :disabled="!loaded" @click="$refs.character?.undoAppearance()">Markierung rückgängig</button>
|
|
<button type="button" :disabled="!loaded" @click="$refs.character?.clearAppearance()">Alle Markierungen löschen</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 'Mit kleinem Pinsel nur die farbige Iris markieren. Der Kreis zeigt die Pinselgröße; 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>
|