feat: 3D VRM supported as scene
This commit is contained in:
@@ -23,3 +23,4 @@ node_modules
|
||||
coverage/
|
||||
**/public/assets/js/*
|
||||
**/public/assets/live2d/models/*
|
||||
**/public/assets/vrm/models/*
|
||||
|
||||
@@ -25,6 +25,7 @@ words:
|
||||
- cubismviewmatrix
|
||||
- demi
|
||||
- elevenlabs
|
||||
- gltf
|
||||
- hiyori
|
||||
- iconify
|
||||
- intlify
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect } from 'vue'
|
||||
import TransitionVertical from './TransitionVertical.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
default?: boolean
|
||||
label?: string
|
||||
}>()
|
||||
const isVisible = defineModel<boolean>({ default: false })
|
||||
watchEffect(() => {
|
||||
if (props.default != null) {
|
||||
isVisible.value = !!props.default
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
|
||||
<button
|
||||
sticky top-0 z-10 flex items-center justify-between px2 py1 text-sm backdrop-blur-xl
|
||||
@click="isVisible = !isVisible"
|
||||
>
|
||||
<span>
|
||||
<slot name="label">
|
||||
{{ props.label ?? 'Collapsable' }}
|
||||
</slot>
|
||||
</span> <span op50>{{ isVisible ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
<TransitionVertical>
|
||||
<div v-if="isVisible" w-full>
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionVertical>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
values: number[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:values', value: number[]): void
|
||||
(e: 'mousedown', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const sliderRef = ref<HTMLElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
const previousIndex = ref<number>(0)
|
||||
|
||||
// Utility functions
|
||||
const asc = (a: number, b: number) => a - b
|
||||
|
||||
function findClosest(values: number[], currentValue: number) {
|
||||
const { index: closestIndex } = values.reduce((acc: { distance: number, index: number } | null, value: number, index: number) => {
|
||||
const distance = Math.abs(currentValue - value)
|
||||
if (acc === null || distance < acc.distance || distance === acc.distance) {
|
||||
return { distance, index }
|
||||
}
|
||||
return acc
|
||||
}, null) || { index: 0 }
|
||||
return closestIndex
|
||||
}
|
||||
|
||||
function valueToPercent(value: number, min: number, max: number) {
|
||||
return ((value - min) * 100) / (max - min)
|
||||
}
|
||||
|
||||
function percentToValue(percent: number, min: number, max: number) {
|
||||
return (max - min) * percent + min
|
||||
}
|
||||
|
||||
function getDecimalPrecision(num: number) {
|
||||
if (Math.abs(num) < 1) {
|
||||
const parts = num.toExponential().split('e-')
|
||||
const matissaDecimalPart = parts[0].split('.')[1]
|
||||
return (matissaDecimalPart ? matissaDecimalPart.length : 0) + Number.parseInt(parts[1], 10)
|
||||
}
|
||||
const decimalPart = num.toString().split('.')[1]
|
||||
return decimalPart ? decimalPart.length : 0
|
||||
}
|
||||
|
||||
function roundValueToStep(value: number, step: number) {
|
||||
const nearest = Math.round(value / step) * step
|
||||
return Number(nearest.toFixed(getDecimalPrecision(step)))
|
||||
}
|
||||
|
||||
// Computed values
|
||||
const sortedValues = computed(() => {
|
||||
return [...props.values]
|
||||
.sort(asc)
|
||||
.map(value => clamp(value, props.min, props.max))
|
||||
})
|
||||
|
||||
const sliderStyle = computed(() => {
|
||||
const sliderOffset = valueToPercent(sortedValues.value[0], props.min, props.max)
|
||||
const sliderLeap = valueToPercent(sortedValues.value[sortedValues.value.length - 1], props.min, props.max) - sliderOffset
|
||||
return {
|
||||
left: `${sliderOffset}%`,
|
||||
width: `${sliderLeap}%`,
|
||||
backgroundSize: `${sliderLeap}% 100%`,
|
||||
}
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
function getNewValue(event: MouseEvent, move = false) {
|
||||
if (!sliderRef.value)
|
||||
return { newValue: sortedValues.value, activeIndex: 0 }
|
||||
|
||||
const { width, left } = sliderRef.value.getBoundingClientRect()
|
||||
const percent = (event.clientX - left) / width
|
||||
|
||||
let currentValue = percentToValue(percent, props.min, props.max)
|
||||
currentValue = roundValueToStep(currentValue, props.step)
|
||||
currentValue = clamp(currentValue, props.min, props.max)
|
||||
|
||||
const activeIndex = move ? previousIndex.value : findClosest(sortedValues.value, currentValue)
|
||||
|
||||
const newValues = [...sortedValues.value]
|
||||
newValues[activeIndex] = currentValue
|
||||
const sortedNewValues = [...newValues].sort(asc)
|
||||
|
||||
const newActiveIndex = sortedNewValues.indexOf(currentValue)
|
||||
previousIndex.value = newActiveIndex
|
||||
|
||||
return {
|
||||
newValue: sortedNewValues,
|
||||
activeIndex: newActiveIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
if (props.disabled)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
isDragging.value = true
|
||||
emit('mousedown', event)
|
||||
|
||||
const { newValue } = getNewValue(event)
|
||||
emit('update:values', newValue)
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging.value || props.disabled)
|
||||
return
|
||||
|
||||
const { newValue } = getNewValue(event, true)
|
||||
emit('update:values', newValue)
|
||||
}
|
||||
|
||||
function handleMouseUp(_: MouseEvent) {
|
||||
if (!isDragging.value)
|
||||
return
|
||||
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function handleMouseLeave(event: MouseEvent) {
|
||||
if (!isDragging.value)
|
||||
return
|
||||
handleMouseUp(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="sliderRef"
|
||||
class="range-slider"
|
||||
:class="{ disabled }"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<span
|
||||
class="slider-track"
|
||||
:style="sliderStyle"
|
||||
/>
|
||||
<span
|
||||
v-for="(value, index) in sortedValues"
|
||||
:key="index"
|
||||
role="slider"
|
||||
class="slider-thumb"
|
||||
:style="{ left: `${valueToPercent(value, min, max)}%` }"
|
||||
:data-index="index"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.range-slider {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
border: 3px solid #4bb9fd;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.range-slider.disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
display: block;
|
||||
position: relative;
|
||||
background-color: #4bb9fd;
|
||||
background-image: linear-gradient(90deg, var(--primary-light), var(--primary-light));
|
||||
background-repeat: no-repeat;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void
|
||||
(e: 'mousedown', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const sliderRef = ref<HTMLElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Scale factor for internal calculations
|
||||
const SCALE_FACTOR = 100
|
||||
|
||||
function getStepPrecision(step: number): number {
|
||||
const stepStr = step.toString()
|
||||
if (stepStr.includes('e-')) {
|
||||
return Number.parseInt(stepStr.split('e-')[1], 10)
|
||||
}
|
||||
const decimals = stepStr.includes('.') ? stepStr.split('.')[1].length : 0
|
||||
return decimals
|
||||
}
|
||||
|
||||
function scaleUp(value: number): number {
|
||||
return value * SCALE_FACTOR
|
||||
}
|
||||
|
||||
function scaleDown(value: number): number {
|
||||
return value / SCALE_FACTOR
|
||||
}
|
||||
|
||||
function roundToStep(value: number, step: number): number {
|
||||
const scaledValue = scaleUp(value)
|
||||
const scaledStep = scaleUp(step)
|
||||
const precision = getStepPrecision(step)
|
||||
|
||||
const rounded = Math.round(scaledValue / scaledStep) * scaledStep
|
||||
const scaledBack = scaleDown(rounded)
|
||||
|
||||
return Number(scaledBack.toFixed(precision))
|
||||
}
|
||||
|
||||
function valueToPercent(value: number, min: number, max: number) {
|
||||
const scaledValue = scaleUp(value)
|
||||
const scaledMin = scaleUp(min)
|
||||
const scaledMax = scaleUp(max)
|
||||
return ((scaledValue - scaledMin) * 100) / (scaledMax - scaledMin)
|
||||
}
|
||||
|
||||
function percentToValue(percent: number, min: number, max: number) {
|
||||
const scaledMin = scaleUp(min)
|
||||
const scaledMax = scaleUp(max)
|
||||
const scaledValue = (scaledMax - scaledMin) * percent + scaledMin
|
||||
const value = scaleDown(scaledValue)
|
||||
return roundToStep(value, props.step)
|
||||
}
|
||||
|
||||
const currentValue = computed(() => {
|
||||
return roundToStep(clamp(props.modelValue, props.min, props.max), props.step)
|
||||
})
|
||||
|
||||
const sliderStyle = computed(() => {
|
||||
const sliderPercent = valueToPercent(currentValue.value, props.min, props.max)
|
||||
return {
|
||||
width: `${sliderPercent}%`,
|
||||
backgroundSize: `${sliderPercent}% 100%`,
|
||||
}
|
||||
})
|
||||
|
||||
function getNewValue(event: MouseEvent) {
|
||||
if (!sliderRef.value)
|
||||
return currentValue.value
|
||||
|
||||
const { width, left } = sliderRef.value.getBoundingClientRect()
|
||||
const percent = clamp((event.clientX - left) / width, 0, 1)
|
||||
return percentToValue(percent, props.min, props.max)
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
if (props.disabled)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
isDragging.value = true
|
||||
emit('mousedown', event)
|
||||
|
||||
const newValue = getNewValue(event)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging.value || props.disabled)
|
||||
return
|
||||
|
||||
const newValue = getNewValue(event)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
function handleMouseUp(_: MouseEvent) {
|
||||
if (!isDragging.value)
|
||||
return
|
||||
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function handleMouseLeave(event: MouseEvent) {
|
||||
if (!isDragging.value)
|
||||
return
|
||||
handleMouseUp(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="sliderRef"
|
||||
class="range-slider disabled:pointer-events-none disabled:cursor-default disabled:opacity-50"
|
||||
:class="{ disabled }"
|
||||
bg="[#e6e1fc] dark:[#676085]" touch-action-none relative inline-block w-full cursor-ew-resize rounded-sm
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<span :style="sliderStyle" bg="[#cabeff] dark:[#4e34b9]" relative block rounded-sm h="[14px]" />
|
||||
<span
|
||||
role="slider"
|
||||
class="slider-thumb"
|
||||
:style="{ left: `${valueToPercent(currentValue, min, max)}%` }"
|
||||
absolute rounded-sm w="[1px]" h="[14px]" bg="zinc-100 dark:zinc-400" top="50%" transform="translate-x-[50%] translate-y-[-50%]"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -20,7 +20,7 @@ import BasicTextarea from './BasicTextarea.vue'
|
||||
// import AudioWaveform from './AudioWaveform.vue'
|
||||
import Live2DViewer from './Live2DViewer.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import ThreeViewer from './ThreeViewer.vue'
|
||||
import ThreeDScene from './ThreeDScene.vue'
|
||||
|
||||
const nowSpeakingAvatarBorderOpacityMin = 30
|
||||
const nowSpeakingAvatarBorderOpacityMax = 100
|
||||
@@ -316,7 +316,7 @@ onUnmounted(() => {
|
||||
</fieldset>
|
||||
<Settings />
|
||||
</div>
|
||||
<div flex="~ row 1" max-h="[calc(100vh-160px)]" relative h-full w-full items-end gap-2>
|
||||
<div flex="~ row 1" max-h="[calc(100vh-210px)]" relative h-full w-full items-end gap-2>
|
||||
<Live2DViewer
|
||||
v-if="stageView === '2d'"
|
||||
ref="live2DViewerRef"
|
||||
@@ -324,14 +324,16 @@ onUnmounted(() => {
|
||||
model="/assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json"
|
||||
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
|
||||
/>
|
||||
<ThreeViewer
|
||||
<ThreeDScene
|
||||
v-else-if="stageView === '3d'"
|
||||
model="/assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm"
|
||||
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
|
||||
@error="console.error"
|
||||
/>
|
||||
<div
|
||||
class="relative <lg:(absolute bottom-0 from-zinc-800/80 to-zinc-800/0 bg-gradient-to-t p-2)"
|
||||
px="<sm:2" py="<sm:2" rounded="<sm:lg"
|
||||
w="50% <lg:full" flex="~ col 1" gap-2 max-h="[calc(100vh-160px)]"
|
||||
w="50% <lg:full" flex="~ col 1" gap-2 max-h="[calc(100vh-210px)]"
|
||||
>
|
||||
<div v-for="(message, index) in messages" :key="index">
|
||||
<div v-if="message.role === 'assistant'" flex mr="12">
|
||||
@@ -413,7 +415,7 @@ onUnmounted(() => {
|
||||
v-model="messageInput"
|
||||
placeholder="Message"
|
||||
p="2" bg="zinc-100 dark:zinc-700"
|
||||
w="[95%]" rounded-lg outline-none
|
||||
w="[95%]" rounded-lg outline-none min-h="[100px]"
|
||||
@submit="onSendMessage"
|
||||
/>
|
||||
<button
|
||||
|
||||
+1
-11
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { OrbitControls } from '@tresjs/cientos'
|
||||
import { TresCanvas } from '@tresjs/core'
|
||||
import { breakpointsTailwind, useBreakpoints, useElementBounding, useWindowSize } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -53,14 +51,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" h-full w-full>
|
||||
<TresCanvas :alpha="true" :antialias="true" :width="canvasWidth" :height="canvasHeight">
|
||||
<TresPerspectiveCamera />
|
||||
<TresMesh>
|
||||
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
|
||||
<TresMeshBasicMaterial color="orange" />
|
||||
</TresMesh>
|
||||
<OrbitControls />
|
||||
<TresAmbientLight :intensity="1" />
|
||||
</TresCanvas>
|
||||
<slot :width="canvasWidth" height="canvasHeight" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { OrbitControls } from '@tresjs/cientos'
|
||||
import { TresCanvas } from '@tresjs/core'
|
||||
|
||||
import Collapsable from './Collapsable.vue'
|
||||
import DataGuiRange from './DataGui/Range.vue'
|
||||
import VRMModel from './VRMModel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
model: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loadModelProgress', value: number): void
|
||||
(e: 'error', value: unknown): void
|
||||
}>()
|
||||
|
||||
const cameraPositionX = ref(0)
|
||||
const cameraPositionY = ref(0.15)
|
||||
const cameraPositionZ = ref(-1)
|
||||
const vrmModelPositionX = ref(0)
|
||||
const vrmModelPositionY = ref(-1.3)
|
||||
const vrmModelPositionZ = ref(-0.3)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ canvasHeight, canvasWidth }" relative>
|
||||
<div z="10" top="2" absolute w-full gap-2 px-2 flex="~ col md:row">
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Camera</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-2 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ cameraPositionX }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ cameraPositionY }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ cameraPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Model</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-2 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
</div>
|
||||
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ vrmModelPositionX }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ vrmModelPositionY }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ vrmModelPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
<TresCanvas :alpha="true" :antialias="true" :width="canvasWidth" :height="canvasHeight">
|
||||
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
|
||||
<OrbitControls />
|
||||
<VRMModel
|
||||
:model="props.model"
|
||||
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
|
||||
@load-model-progress="(val) => emit('loadModelProgress', val)"
|
||||
@error="(val) => emit('error', val)"
|
||||
/>
|
||||
<TresAmbientLight :intensity="1" />
|
||||
</TresCanvas>
|
||||
</Screen>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
// From: https://stackoverflow.com/a/71426342/22392721
|
||||
interface Props {
|
||||
duration?: number
|
||||
easingEnter?: string
|
||||
easingLeave?: string
|
||||
opacityClosed?: number
|
||||
opacityOpened?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 250,
|
||||
easingEnter: 'ease-in-out',
|
||||
easingLeave: 'ease-in-out',
|
||||
opacityClosed: 0,
|
||||
opacityOpened: 1,
|
||||
})
|
||||
|
||||
const closed = '0px'
|
||||
|
||||
interface initialStyle {
|
||||
height: string
|
||||
width: string
|
||||
position: string
|
||||
visibility: string
|
||||
overflow: string
|
||||
paddingTop: string
|
||||
paddingBottom: string
|
||||
borderTopWidth: string
|
||||
borderBottomWidth: string
|
||||
marginTop: string
|
||||
marginBottom: string
|
||||
}
|
||||
|
||||
function getElementStyle(element: HTMLElement) {
|
||||
return {
|
||||
height: element.style.height,
|
||||
width: element.style.width,
|
||||
position: element.style.position,
|
||||
visibility: element.style.visibility,
|
||||
overflow: element.style.overflow,
|
||||
paddingTop: element.style.paddingTop,
|
||||
paddingBottom: element.style.paddingBottom,
|
||||
borderTopWidth: element.style.borderTopWidth,
|
||||
borderBottomWidth: element.style.borderBottomWidth,
|
||||
marginTop: element.style.marginTop,
|
||||
marginBottom: element.style.marginBottom,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
|
||||
const { width } = getComputedStyle(element)
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = ''
|
||||
const { height } = getComputedStyle(element)
|
||||
element.style.width = initialStyle.width
|
||||
element.style.position = initialStyle.position
|
||||
element.style.visibility = initialStyle.visibility
|
||||
element.style.height = closed
|
||||
element.style.overflow = 'hidden'
|
||||
return initialStyle.height && initialStyle.height !== closed
|
||||
? initialStyle.height
|
||||
: height
|
||||
}
|
||||
|
||||
function animateTransition(
|
||||
element: HTMLElement,
|
||||
initialStyle: initialStyle,
|
||||
done: () => void,
|
||||
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
|
||||
options?: number | KeyframeAnimationOptions,
|
||||
) {
|
||||
const animation = element.animate(keyframes, options)
|
||||
// Set height to 'auto' to restore it after animation
|
||||
element.style.height = initialStyle.height
|
||||
animation.onfinish = () => {
|
||||
element.style.overflow = initialStyle.overflow
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
return [
|
||||
{
|
||||
height: closed,
|
||||
opacity: props.opacityClosed,
|
||||
paddingTop: closed,
|
||||
paddingBottom: closed,
|
||||
borderTopWidth: closed,
|
||||
borderBottomWidth: closed,
|
||||
marginTop: closed,
|
||||
marginBottom: closed,
|
||||
},
|
||||
{
|
||||
height,
|
||||
opacity: props.opacityOpened,
|
||||
paddingTop: initialStyle.paddingTop,
|
||||
paddingBottom: initialStyle.paddingBottom,
|
||||
borderTopWidth: initialStyle.borderTopWidth,
|
||||
borderBottomWidth: initialStyle.borderBottomWidth,
|
||||
marginTop: initialStyle.marginTop,
|
||||
marginBottom: initialStyle.marginBottom,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const height = prepareElement(HTMLElement, initialStyle)
|
||||
const keyframes = getEnterKeyframes(height, initialStyle)
|
||||
const options = { duration: props.duration, easing: props.easingEnter }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const { height } = getComputedStyle(HTMLElement)
|
||||
HTMLElement.style.height = height
|
||||
HTMLElement.style.overflow = 'hidden'
|
||||
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
|
||||
const options = { duration: props.duration, easing: props.easingLeave }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<slot />
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { VRMCore } from '@pixiv/three-vrm'
|
||||
import { VRMLoaderPlugin } from '@pixiv/three-vrm'
|
||||
import { useTresContext } from '@tresjs/core'
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||
|
||||
const props = defineProps<{
|
||||
model: string
|
||||
position: [number, number, number]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loadModelProgress', value: number): void
|
||||
(e: 'error', value: unknown): void
|
||||
}>()
|
||||
|
||||
let vrm: VRMCore
|
||||
const { scene } = useTresContext()
|
||||
|
||||
interface GLTFUserdata extends Record<string, any> {
|
||||
vrm: VRMCore
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!scene.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
loader.register(parser => new VRMLoaderPlugin(parser))
|
||||
loader.load(
|
||||
props.model,
|
||||
(gltf) => {
|
||||
const userData = gltf.userData as GLTFUserdata
|
||||
vrm = userData.vrm as VRMCore
|
||||
scene.value.add(vrm.scene)
|
||||
vrm.scene.position.set(...props.position)
|
||||
},
|
||||
progress => emit('loadModelProgress', Number.parseFloat((100.0 * (progress.loaded / progress.total)).toFixed(2))),
|
||||
error => emit('error', error),
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (vrm) {
|
||||
const { scene } = useTresContext()
|
||||
scene.value.remove(vrm.scene)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.position, ([x, y, z]) => {
|
||||
if (vrm) {
|
||||
vrm.scene.position.set(x, y, z)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -241,5 +241,63 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vrm-models-sample-a',
|
||||
async configResolved(config) {
|
||||
const cacheDir = resolve(join(config.root, '.cache'))
|
||||
const publicDir = resolve(join(config.root, 'public'))
|
||||
|
||||
try {
|
||||
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-A'))))) {
|
||||
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
|
||||
|
||||
console.log('Downloading VRM Model - Avatar Sample A...')
|
||||
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', { responseType: 'arrayBuffer' })
|
||||
|
||||
console.log('Saving VRM Model - Avatar Sample A...')
|
||||
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm'), Buffer.from(res))
|
||||
|
||||
console.log('VRM Model - Avatar Sample A downloaded and saved.')
|
||||
}
|
||||
|
||||
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-A'))))) {
|
||||
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true }).catch(() => { })
|
||||
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vrm-models-sample-b',
|
||||
async configResolved(config) {
|
||||
const cacheDir = resolve(join(config.root, '.cache'))
|
||||
const publicDir = resolve(join(config.root, 'public'))
|
||||
|
||||
try {
|
||||
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-B'))))) {
|
||||
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
|
||||
|
||||
console.log('Downloading VRM Model - Avatar Sample B...')
|
||||
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', { responseType: 'arrayBuffer' })
|
||||
|
||||
console.log('Saving VRM Model - Avatar Sample B...')
|
||||
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'), Buffer.from(res))
|
||||
}
|
||||
|
||||
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-B'))))) {
|
||||
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true }).catch(() => { })
|
||||
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user