feat(stage-ui): VRM model setting page: FOV and Rotation (#307)

---------

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Lilia_Chen
2025-07-21 19:17:06 +08:00
committed by GitHub
co-authored by Neko
parent 1785c39fee
commit a8454b4a44
9 changed files with 362 additions and 135 deletions
+3 -1
View File
@@ -562,8 +562,10 @@ vrm:
title: Scale And Position
scale: Scale
x: X Offset
y: Y Offset
'y': Y Offset
z: Z Offset
fov: FOV (degree)
rotation-y: Rotation (Y-axis)
switch-to-vrm:
title: Switch to Live2D Avatar?
change-to-vrm: Click here to switch to the Live2D avatar setting
@@ -538,8 +538,10 @@ vrm:
title: 缩放与位置
scale: 缩放
x: X轴偏移
y: Y 轴偏移
'y': Y 轴偏移
z: Z轴偏移
fov: 视角调整(度)
rotation-y: 模型朝向(Y轴旋转)
switch-to-vrm:
title: 想切换至Live2D虚拟形象?
change-to-vrm: 切换至Live2D虚拟形象设定页面
@@ -31,9 +31,11 @@ const {
loadSource,
loadingModel,
modelUrl,
scale,
modelSize,
modelOffset,
cameraFOV,
selectedModel,
modelRotationY,
} = storeToRefs(vrm)
const localModelUrl = ref(modelUrl.value)
@@ -42,8 +44,28 @@ modelFileDialog.onChange((files) => {
modelFile.value = files[0]
loadSource.value = 'file'
loadingModel.value = true
localModelUrl.value = ''
}
})
function urlUploadClick() {
modelUrl.value = localModelUrl.value
// same URL will let the loader be lazy and forgot to reset loading state
// If the loading state is still true, then the URL input will be locked
if (modelUrl.value === selectedModel.value) {
console.warn('Model URL is the same as the selected model, no need to reload.')
return
}
// Can't let the default model URL be reentered into the loader, otherwise it will still be too lazy to reset the loading state
if (!modelUrl.value && selectedModel.value === vrm.defaultModelUrl) {
localModelUrl.value = vrm.defaultModelUrl
return
}
// Only when real different URL is entered, then the loader will be triggered
loadSource.value = 'url'
loadingModel.value = true
localModelUrl.value = selectedModel.value
}
</script>
<template>
@@ -60,6 +82,96 @@ modelFileDialog.onChange((files) => {
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button
variant="secondary" @click=" () => {
modelFileDialog.reset()
modelFileDialog.open()
}"
>
{{ t('settings.vrm.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="urlUploadClick">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
</Section>
<Section
:title="t('settings.vrm.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button
variant="secondary" @click=" () => {
modelFileDialog.reset()
modelFileDialog.open()
}"
>
{{ t('settings.vrm.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="urlUploadClick">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
</Section>
<Section
:title="t('settings.vrm.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
@@ -69,10 +181,7 @@ modelFileDialog.onChange((files) => {
'backdrop-blur-lg',
]"
>
<Callout
:label="t('settings.vrm.scale-and-position.model-info-title')"
w-full
>
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
<div>
<div class="text-sm text-neutral-600 space-y-1">
<div class="flex justify-between">
@@ -93,37 +202,16 @@ modelFileDialog.onChange((files) => {
<Callout
theme="lime"
label="Tips!"
w-full
>
<div class="text-sm text-neutral-600 space-y-1">
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
v-model="scale"
as="div"
:min="0"
:max="5"
:step="scale / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.scale') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.x"
as="div"
:min="-modelSize.x - 10"
:max="modelSize.x + 10"
:min="-modelSize.x * 2"
:max="modelSize.x * 2"
:step="modelSize.x / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
@@ -142,8 +230,8 @@ modelFileDialog.onChange((files) => {
<FieldRange
v-model="modelOffset.y"
as="div"
:min="-modelSize.y - 10"
:max="modelSize.y + 10"
:min="-modelSize.y * 2"
:max="modelSize.y * 2"
:step="modelSize.y / 100"
:label="t('settings.vrm.scale-and-position.y')"
:format-value="val => val.toFixed(4)"
@@ -158,10 +246,10 @@ modelFileDialog.onChange((files) => {
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.z"
v-model="modelOffset.y"
as="div"
:min="-modelSize.z - 10"
:max="modelSize.z + 10"
:min="-modelSize.z * 2"
:max="modelSize.z * 2"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.z')"
:format-value="val => val.toFixed(4)"
@@ -175,45 +263,41 @@ modelFileDialog.onChange((files) => {
</div>
</template>
</FieldRange>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button variant="secondary" @click="modelFileDialog.open()">
{{ t('settings.vrm.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="() => { modelUrl = localModelUrl; loadSource = 'url'; loadingModel = true }">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
</Section>
<Section
:title="t('settings.vrm.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
<FieldRange
:model-value="cameraFOV"
as="div"
:min="1"
:max="180"
:step="1"
:label="t('settings.vrm.scale-and-position.fov')"
@update:model-value="val => cameraFOV = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.fov') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => cameraFOV = 40">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
:model-value="modelRotationY"
as="div"
:min="-180"
:max="180"
:step="1"
:label="t('settings.vrm.scale-and-position.rotation-y')"
@update:model-value="val => modelRotationY = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.rotation-y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelRotationY = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
</template>
+60 -26
View File
@@ -1,34 +1,68 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { onMounted, ref, shallowRef, watch } from 'vue'
import * as THREE from 'three'
import { useVRM } from '../../stores'
import { Screen } from '../Misc'
import { VRMModel } from '../Scenes'
const props = withDefaults(defineProps<{
paused?: boolean
showAxes?: boolean
}>(), {
paused: false,
showAxes: false,
})
import { OrbitControls, VRMModel } from '../Scenes'
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const { selectedModel, scale } = storeToRefs(useVRM())
const vrmContainerRef = ref<HTMLDivElement>()
const { width, height } = useElementBounding(vrmContainerRef)
const {
selectedModel,
cameraFOV,
initialCameraPosition,
} = storeToRefs(useVRM())
const cameraPositionX = ref(0.17)
const cameraPositionY = ref(0.5)
const cameraPositionZ = ref(-1)
const modelRef = ref<InstanceType<typeof VRMModel>>()
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
const camera = shallowRef(new THREE.PerspectiveCamera())
onMounted(() => {
if (vrmContainerRef.value) {
camera.value.aspect = width.value / height.value
camera.value.fov = cameraFOV.value
camera.value.position.set(
initialCameraPosition.value.x,
initialCameraPosition.value.y,
initialCameraPosition.value.z,
)
camera.value.updateProjectionMatrix()
}
})
watch(cameraFOV, (newFov) => {
if (camera.value) {
camera.value.fov = newFov
camera.value.updateProjectionMatrix()
}
})
// not sure if we need this
// watch([width, height], () => {
// if (camera.value) {
// camera.value.aspect = width.value / height.value
// camera.value.updateProjectionMatrix()
// }
// })
watch(selectedModel, () => {
if (camera.value) {
camera.value.position.set(
initialCameraPosition.value.x,
initialCameraPosition.value.y,
initialCameraPosition.value.z,
)
camera.value.updateProjectionMatrix()
}
})
defineExpose({
setExpression: (expression: string) => {
@@ -38,11 +72,9 @@ defineExpose({
</script>
<template>
<Screen v-slot="{ width, height }" relative>
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls :enable-zoom="false" />
<TresAxesHelper v-if="props.showAxes" :size="1" />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" :zoom="scale" />
<div ref="vrmContainerRef" w="100%" h="100%">
<TresCanvas v-if="camera" :camera="camera" :alpha="true" :antialias="true" :width="width" :height="height">
<TresAxesHelper :size="1" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
@@ -50,10 +82,12 @@ defineExpose({
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="props.paused"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@error="(val) => emit('error', val)"
/>
<!-- <TresPerspectiveCamera :position="[initialCameraPosition.x, initialCameraPosition.y, initialCameraPosition.z]" :fov="cameraFOV"/> -->
<OrbitControls />
</TresCanvas>
</Screen>
</div>
</template>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { VRMCore } from '@pixiv/three-vrm-core'
import type { AnimationClip } from 'three'
import type { AnimationClip, Group } from 'three'
import { VRMUtils } from '@pixiv/three-vrm'
import { useLoop, useTresContext } from '@tresjs/core'
import { storeToRefs } from 'pinia'
import { AnimationMixer } from 'three'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { AnimationMixer, MathUtils, Quaternion, Vector3 } from 'three'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { clipFromVRMAnimation, loadVRMAnimation, useBlink, useIdleEyeSaccades } from '../../../composables/vrm/animation'
import { loadVrm } from '../../../composables/vrm/core'
@@ -41,21 +41,15 @@ const {
modelOrigin,
modelSize,
position,
scale,
initialCameraPosition,
loadingModel,
modelRotationY,
} = storeToRefs(vrmStore)
watch(modelOffset, () => {
if (vrm.value) {
vrm.value.scene.position.set(
position.value.x,
position.value.y,
position.value.z,
)
}
}, { deep: true })
const vrmGroup = ref<Group>()
onMounted(async () => {
if (!scene.value) {
console.warn('Scene is not ready, cannot load VRM model.')
return
}
@@ -66,12 +60,21 @@ onMounted(async () => {
positionOffset: [modelOffset.value.x, modelOffset.value.y, modelOffset.value.z],
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
})
if (!_vrmInfo) {
if (!_vrmInfo || !_vrmInfo._vrm) {
console.warn('No VRM model loaded')
return
}
const {
_vrm,
_vrmGroup,
modelCenter: vrmModelCenter,
modelSize: vrmModelSize,
initialCameraPosition: vrmInitialCameraPosition,
} = _vrmInfo
const { _vrm, modelCenter: vrmModelCenter, modelSize: vrmModelSize } = _vrmInfo
vrmGroup.value = _vrmGroup
// Set initial camera position
initialCameraPosition.value = vrmInitialCameraPosition
// Set initial positions for model
modelOrigin.value = {
@@ -85,6 +88,22 @@ onMounted(async () => {
z: vrmModelSize.z,
}
// Set model facing direction
const targetDirection = new Vector3(0, 0, -1) // Default facing direction
const lookAtTarget = _vrm.lookAt
if (lookAtTarget) {
const facingDirection = lookAtTarget.faceFront
const quaternion = new Quaternion()
quaternion.setFromUnitVectors(facingDirection.normalize(), targetDirection.normalize())
_vrm.scene.quaternion.premultiply(quaternion)
_vrm.scene.updateMatrixWorld(true)
}
else {
console.warn('No look-at target found in VRM model')
}
// Reset model rotation Y
modelRotationY.value = 0
// Set initial positions for animation
function removeRootPositionTrack(clip: AnimationClip) {
clip.tracks = clip.tracks.filter((track) => {
@@ -108,7 +127,8 @@ onMounted(async () => {
vrmEmote.value = useVRMEmote(_vrm)
vrm.value = _vrm
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
loadingModel.value = false
disposeBeforeRenderLoop = onBeforeRender(({ delta }) => {
vrmAnimationMixer.value?.update(delta)
@@ -119,10 +139,28 @@ onMounted(async () => {
}).off
}
catch (err) {
// This is needed otherwise the URL input will be locked forever...
loadingModel.value = false
emit('error', err)
}
})
watch(modelOffset, () => {
if (vrmGroup.value) {
vrmGroup.value.position.set(
position.value.x,
position.value.y,
position.value.z,
)
}
}, { deep: true })
watch(modelRotationY, (newRotationY) => {
if (vrm.value && vrmGroup.value) {
vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY)
}
})
onUnmounted(() => {
disposeBeforeRenderLoop?.()
if (vrm.value) {
@@ -135,6 +173,7 @@ defineExpose({
setExpression(expression: string) {
vrmEmote.value?.setEmotionWithResetAfter(expression, 1000)
},
scene: computed(() => vrm.value?.scene),
})
const { pause, resume } = useLoop()
@@ -142,12 +181,6 @@ const { pause, resume } = useLoop()
watch(() => props.paused, (value) => {
value ? pause() : resume()
})
watch(scale, () => {
if (vrm.value) {
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
}
}, { immediate: true })
</script>
<template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { extend, useTresContext } from '@tresjs/core'
import { storeToRefs } from 'pinia'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { onMounted, shallowRef } from 'vue'
import * as THREE from 'three'
import { useVRM } from '../../../stores'
extend({ OrbitControls })
const { camera, renderer } = useTresContext()
const controls = shallowRef<OrbitControls>()
const {
modelSize,
} = storeToRefs(useVRM())
// initialize the OrbitControls
onMounted(() => {
if (camera.value && renderer.value?.domElement) {
controls.value = new OrbitControls(camera.value, renderer.value.domElement)
controls.value.enableDamping = true
controls.value.dampingFactor = 0.5
// Align to tresjs conventions
controls.value.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}
controls.value.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}
controls.value.enablePan = false
controls.value.minDistance = modelSize.value.z
controls.value.update()
}
})
</script>
<template>
<slot />
</template>
@@ -1 +1,2 @@
export { default as VRMModel } from './Model.vue'
export { default as OrbitControls } from './OrbitControls.vue'
+22 -7
View File
@@ -3,7 +3,7 @@ import type { Object3D, Scene } from 'three'
import { VRMUtils } from '@pixiv/three-vrm'
import { VRMLookAtQuaternionProxy } from '@pixiv/three-vrm-animation'
import { Box3, Vector3 } from 'three'
import { Box3, Group, Vector3 } from 'three'
import { useVRMLoader } from './loader'
@@ -16,7 +16,7 @@ export async function loadVrm(model: string, options?: {
scene?: Scene
lookAt?: boolean
onProgress?: (progress: ProgressEvent<EventTarget>) => void | Promise<void>
}): Promise<{ _vrm: VRMCore, modelCenter: Vector3, modelSize: Vector3 } | undefined> {
}): Promise<{ _vrm: VRMCore, _vrmGroup: Group, modelCenter: Vector3, modelSize: Vector3, initialCameraPosition: Vector3 } | undefined> {
const loader = useVRMLoader()
const gltf = await loader.loadAsync(model, progress => options?.onProgress?.(progress))
@@ -43,9 +43,12 @@ export async function loadVrm(model: string, options?: {
_vrm.scene.add(lookAtQuatProxy)
}
const _vrmGroup = new Group()
_vrmGroup.add(_vrm.scene)
// Add to scene
if (options?.scene)
options.scene.add(_vrm.scene)
if (options?.scene) {
options.scene.add(_vrmGroup)
}
// Move the VRM model centre to the (0, 0, 0)
const box = new Box3().setFromObject(_vrm.scene)
@@ -54,23 +57,35 @@ export async function loadVrm(model: string, options?: {
box.getSize(modelSize)
box.getCenter(modelCenter)
modelCenter.negate()
modelCenter.y -= modelSize.y / 8 // Adjust pivot to align chest with the origin
modelCenter.y -= modelSize.y / 5 // Adjust pivot to align chest with the origin
// Set position
if (options?.positionOffset) {
_vrm.scene.position.set(
_vrmGroup.position.set(
modelCenter.x + options.positionOffset[0],
modelCenter.y + options.positionOffset[1],
modelCenter.z + options.positionOffset[2],
)
}
else {
_vrm.scene.position.set(modelCenter.x, modelCenter.y, modelCenter.z)
_vrmGroup.position.set(modelCenter.x, modelCenter.y, modelCenter.z)
}
// Compute the initial camera position (once per loaded model)
// In order to see the up-2/3 part fo the model, z = (y/3) / tan(fov/2)
const fov = 40 // default fov = 40 degrees
const radians = (fov / 2 * Math.PI) / 180
const initialCameraPosition = new Vector3(
modelSize.x / 16,
modelSize.y / 6,
-(modelSize.y / 3) / Math.tan(radians), // default z value
)
return {
_vrm,
_vrmGroup,
modelCenter,
modelSize,
initialCameraPosition,
}
}
+13 -4
View File
@@ -4,7 +4,10 @@ import { computed, ref, watch } from 'vue'
export const useVRM = defineStore('vrm', () => {
const modelFile = ref<File>()
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
const defaultModelUrl = '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
const modelUrl = useLocalStorage('settings/vrm/modelURL', defaultModelUrl)
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false)
@@ -23,8 +26,11 @@ export const useVRM = defineStore('vrm', () => {
y: `${position.value.y}%`,
z: `${position.value.z}%`,
}))
const cameraFOV = useLocalStorage('settings/vrm/cameraFOV', 40)
const initialCameraPosition = useLocalStorage('settings/vrm/initialCameraPosition', { x: 0, y: 0, z: -1 })
const modelObjectUrl = ref<string>()
const modelRotationY = useLocalStorage('settings/vrm/modelRotationY', 0)
// Manage the object URL lifecycle to prevent memory leaks
watch(modelFile, (newFile) => {
@@ -37,7 +43,6 @@ export const useVRM = defineStore('vrm', () => {
}
})
// Centralized computed property for the model source
const selectedModel = computed(() => {
if (loadSource.value === 'file' && modelObjectUrl.value) {
return modelObjectUrl.value
@@ -46,11 +51,12 @@ export const useVRM = defineStore('vrm', () => {
return modelUrl.value
}
// Fallback model
return '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
return defaultModelUrl
})
return {
modelFile,
defaultModelUrl,
modelUrl,
loadSource,
loadingModel,
@@ -60,6 +66,9 @@ export const useVRM = defineStore('vrm', () => {
modelOffset,
position,
positionInPercentageString,
selectedModel, // Expose the new computed property
selectedModel,
cameraFOV,
initialCameraPosition,
modelRotationY,
}
})