Files
yourpart3/frontend/src/components/falukant/CharacterAppearanceEditor.vue
Torsten Schulz (local) dd409a6a6a
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s
Ersetze grobe 3D-Markierungen durch präzise UV-Farbmasken
2026-11-04 16:16:01 +01:00

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>