diff --git a/frontend/src/components/falukant/House3D.vue b/frontend/src/components/falukant/House3D.vue index 2517771..5217607 100644 --- a/frontend/src/components/falukant/House3D.vue +++ b/frontend/src/components/falukant/House3D.vue @@ -8,12 +8,20 @@ import { markRaw } from 'vue'; const MODEL_BASE_PATH = '/models/3d/falukant/houses'; +const CHARACTER_MODELS_API_PATH = '/api/models/3d/falukant/characters'; +const CHARACTER_MODEL_AGES = Object.freeze([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 25, 30, 40, 50, 60]); +const CHARACTER_MODEL_ASSET_REVISION = '20260930-2'; export default { name: 'House3D', props: { model: { type: String, required: true }, label: { type: String, default: '' }, + // When supplied, the character is loaded into the same Three.js scene as + // the house. This keeps their depth relationship intact while rotating or + // zooming the overview scene. + characterGender: { type: String, default: null }, + characterAge: { type: Number, default: null }, // List previews must not keep a WebGL context while they are outside the // scroll viewport. A page with several houses otherwise exhausts the // browser's per-site context limit after navigating around Falukant. @@ -25,6 +33,7 @@ export default { camera: null, renderer: null, renderedModel: null, + renderedCharacter: null, controls: null, onControlsChange: null, resizeObserver: null, @@ -38,6 +47,12 @@ export default { model() { if (this.isVisible) this.loadModel(); }, + characterGender() { + if (this.isVisible) this.loadModel(); + }, + characterAge() { + if (this.isVisible) this.loadModel(); + }, }, async mounted() { if (!this.lazy) { @@ -110,18 +125,18 @@ export default { const lifecycleToken = this.lifecycleToken; this.loadFailed = false; this.removeModel(); + let dracoLoader = null; try { const [loaders, modelRuntime] = await Promise.all([ import('@/utils/threeLoaders.js'), import('@/utils/threeModelRuntime.js'), ]); - const dracoLoader = new loaders.DRACOLoader(); + dracoLoader = new loaders.DRACOLoader(); dracoLoader.setDecoderPath('/draco/gltf/'); const loader = new loaders.GLTFLoader(); loader.setDRACOLoader(dracoLoader); const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`); - dracoLoader.dispose(); if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) { this.disposeObject(gltf.scene); @@ -141,13 +156,62 @@ export default { const center = scaledBox.getCenter(new modelRuntime.Vector3()); this.renderedModel.position.set(-center.x, -scaledBox.min.y, -center.z); this.scene.add(this.renderedModel); + + await this.loadForegroundCharacter(loader, modelRuntime, lifecycleToken); this.controls?.update(); this.render(); } catch (error) { console.error(`Unable to load 3D house model '${this.model}'`, error); this.loadFailed = true; + } finally { + dracoLoader?.dispose(); } }, + async loadForegroundCharacter(loader, modelRuntime, lifecycleToken) { + const gender = this.characterGender === 'female' ? 'female' : this.characterGender === 'male' ? 'male' : null; + const requestedAge = Number(this.characterAge); + if (!gender || !Number.isFinite(requestedAge)) return; + + const candidateAges = CHARACTER_MODEL_AGES + .filter((age) => age <= requestedAge) + .sort((left, right) => right - left); + let gltf = null; + for (const age of candidateAges) { + try { + gltf = await loader.loadAsync( + `${CHARACTER_MODELS_API_PATH}/${gender}_${age}y_opt.glb?v=${CHARACTER_MODEL_ASSET_REVISION}` + ); + break; + } catch (_) { + // The next lower concrete age is an intentional fallback. + } + } + if (!gltf) return; + + if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) { + this.disposeObject(gltf.scene); + return; + } + + this.renderedCharacter = markRaw(gltf.scene); + const box = new modelRuntime.Box3().setFromObject(this.renderedCharacter); + const size = box.getSize(new modelRuntime.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(maxDimension) || maxDimension <= 0) { + this.disposeObject(this.renderedCharacter); + this.renderedCharacter = null; + return; + } + + const ageScale = requestedAge <= 3 ? 0.6 : requestedAge <= 7 ? 0.8 : requestedAge <= 12 ? 0.9 : 0.98; + this.renderedCharacter.scale.setScalar((1.7 / maxDimension) * ageScale); + const scaledBox = new modelRuntime.Box3().setFromObject(this.renderedCharacter); + const center = scaledBox.getCenter(new modelRuntime.Vector3()); + // The house model is centered at z=0. Place the figure a little in + // front of it, rather than overlaying an unrelated canvas. + this.renderedCharacter.position.set(-center.x, -scaledBox.min.y, 0.62 - center.z); + this.scene.add(this.renderedCharacter); + }, render() { const container = this.$refs.container; if (!container || !this.renderer || !this.camera || !this.scene) return; @@ -159,10 +223,12 @@ export default { this.renderer.render(this.scene, this.camera); }, removeModel() { - if (!this.renderedModel) return; - this.scene.remove(this.renderedModel); - this.disposeObject(this.renderedModel); + [this.renderedModel, this.renderedCharacter].filter(Boolean).forEach((model) => { + this.scene?.remove(model); + this.disposeObject(model); + }); this.renderedModel = null; + this.renderedCharacter = null; }, disposeObject(object3d) { object3d?.traverse((object) => { diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index 551e5da..6e04a0d 100755 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -34,14 +34,9 @@ class="house" :model="houseModel" :label="$t(`falukant.house.type.${falukantUser.userHouse.houseType.labelTr}`)" + :character-gender="falukantUser.character.gender" + :character-age="falukantUser.character.age" /> -