feat(stage-ui): more data pane components

This commit is contained in:
Neko Ayaka
2025-07-24 01:18:12 +08:00
parent 7f5a244fbf
commit f3e6e19b4c
14 changed files with 583 additions and 284 deletions
@@ -46,9 +46,9 @@ const {
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
live-2d-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
live-2d-settings-class="w-40% md:w-30% lg:w-25% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-settings-class="w-40% md:w-30% lg:w-25% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
+1
View File
@@ -113,6 +113,7 @@ words:
- kirakira
- kwaa
- lemonnekogh
- lerp
- libsamplerate
- libsodium
- lightningcss
+9 -2
View File
@@ -154,6 +154,15 @@ pages:
models:
description: Live2D, VRM, etc.
title: Models
sections:
section:
live2d:
title: Live2D
description: Configure Live2D models and settings
vrm:
title: VRM
description: Configure 3D VRM models and settings
scene: Scene
modules:
consciousness:
description: Personality, desired model, etc.
@@ -559,7 +568,6 @@ vrm:
tips: |
Edit the initial position the VRM model.
Coordinate axes are visualised.
title: Scale And Position
scale: Scale
x: X Offset
'y': Y Offset
@@ -571,6 +579,5 @@ vrm:
title: Switch to Live2D Avatar?
change-to-vrm: Click here to switch to the Live2D avatar setting
theme-color-from-model:
title: Extract colors from model
button-extract:
title: Extract
@@ -142,6 +142,9 @@ pages:
models:
description: 切换角色的 Live2DVRM 模型
title: 角色模型
sections:
section:
scene: 场景
modules:
consciousness:
description: 人格设定、使用的模型等配置
@@ -535,7 +538,6 @@ vrm:
tips: |
设定模型放置的空间坐标。
坐标轴已被可视化。
title: 缩放与位置
scale: 缩放
x: X轴偏移
'y': Y 轴偏移
@@ -0,0 +1,11 @@
<script lang="ts" setup>
</script>
<template>
<div
bg="neutral-100 dark:neutral-800"
overflow-y-scroll
>
<slot />
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Range from './Range.vue'
import PropertyNumber from './PropertyNumber.vue'
const minValue = ref(0)
const midValue = ref(50)
@@ -15,15 +15,15 @@ const maxValue = ref(100)
</template>
<Variant id="default-min" title="Default (min)">
<Range v-model="minValue" w-full />
<PropertyNumber v-model="minValue" w-full />
</Variant>
<Variant id="default-mid" title="Default (mid)">
<Range v-model="midValue" w-full />
<PropertyNumber v-model="midValue" w-full />
</Variant>
<Variant id="default-max" title="Default (max)">
<Range v-model="maxValue" w-full />
<PropertyNumber v-model="maxValue" w-full />
</Variant>
</Story>
</template>
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
interface RangeConfig {
min?: number
max?: number
step?: number
label?: string
formatValue?: (val: number) => string
precision?: number
disabled?: boolean
}
const props = withDefaults(defineProps<{
label?: string
config?: RangeConfig
}>(), {
label: 'Range',
})
const modelValue = defineModel<number>({ required: true })
// Dragging state for input
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartValue = ref(0)
const sliderRef = ref<HTMLInputElement>()
function postProcessValue(val: number, config?: RangeConfig): string {
if (config?.formatValue) {
return config.formatValue(val)
}
if (config?.precision !== undefined) {
return val.toFixed(config.precision)
}
return val.toString()
}
const normalizedValue = ref(postProcessValue(modelValue.value, props.config))
watch(modelValue, () => {
normalizedValue.value = postProcessValue(modelValue.value, props.config)
updateSliderProgress()
}, { immediate: true })
function updateSliderProgress() {
if (!sliderRef.value)
return
const min = props.config?.min ?? 0
const max = props.config?.max ?? 100
const value = modelValue.value
sliderRef.value.style.setProperty('--value', value.toString())
sliderRef.value.style.setProperty('--min', min.toString())
sliderRef.value.style.setProperty('--max', max.toString())
}
onMounted(() => {
updateSliderProgress()
})
function handleSliderChange(event: Event) {
const input = event.target as HTMLInputElement
const value = Number.parseFloat(input.value)
if (!Number.isNaN(value)) {
updateValue(value)
}
}
function handleInputChange(event: Event) {
const input = event.target as HTMLInputElement
const value = Number.parseFloat(input.value)
if (Number.isNaN(value))
return
updateValue(value)
}
function updateValue(value: number) {
const config = props.config
const min = config?.min ?? 0
const max = config?.max ?? 100
// Apply min/max constraints
value = Math.max(min, Math.min(max, value))
modelValue.value = value
normalizedValue.value = postProcessValue(value, config)
}
function startDrag(event: MouseEvent) {
if (props.config?.disabled)
return
event.preventDefault()
isDragging.value = true
dragStartX.value = event.clientX
dragStartValue.value = modelValue.value
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.body.style.cursor = 'ew-resize'
}
function onDrag(event: MouseEvent) {
if (!isDragging.value)
return
const deltaX = event.clientX - dragStartX.value
const config = props.config
const sensitivity = config?.step || 0.01
const newValue = dragStartValue.value + (deltaX * sensitivity)
updateValue(newValue)
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.body.style.cursor = ''
}
</script>
<template>
<div>
<slot name="label">
<span text-nowrap text-xs>{{ props.label }}</span>
</slot>
</div>
<div />
<div
h="6.25"
grid-col-span-2 w-full
>
<input
ref="sliderRef"
:value="modelValue"
type="range"
:min="props.config?.min ?? 0"
:max="props.config?.max ?? 100"
:step="props.config?.step ?? 1"
:disabled="props.config?.disabled"
class="range-slider"
h-full w-full appearance-none bg-transparent outline-none
@input="handleSliderChange"
>
</div>
<!-- Value input -->
<label
bg="neutral-100 dark:neutral-900"
border="~ 1 solid neutral-200 dark:neutral-800"
h-fit min-w-16 inline-flex items-center rounded-md px-2 py-1
>
<span h-fit w-full inline-flex items-center gap-2 text-xs>
<span
cursor-col-resize
select-none
:class="{ 'text-blue-500': isDragging }"
@mousedown="startDrag"
>|</span>
<input
:value="normalizedValue"
type="number"
:min="props.config?.min"
:max="props.config?.max"
:step="props.config?.step || 0.0001"
:disabled="props.config?.disabled"
max-w-4lh w-full flex-1 appearance-none bg-transparent text-right text-xs font-mono outline-none
class="[&::-webkit-inner-spin-button]:(m-0 appearance-none)"
@change="handleInputChange"
>
</span>
</label>
</template>
<style scoped>
.range-slider {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--progress: calc(var(--ratio) * 100%);
}
.range-slider::-webkit-slider-runnable-track {
height: 100%;
border-radius: 6px;
background: linear-gradient(
to right,
rgb(198, 198, 198) 0%,
rgba(198, 198, 198) var(--progress),
rgb(236, 236, 236) var(--progress),
rgba(236, 236, 236) 100%
);
backdrop-filter: blur(4px);
cursor: col-resize;
}
.dark .range-slider::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
rgba(151, 151, 151, 0.8) 0%,
rgba(151, 151, 151, 0.8) var(--progress),
rgba(23, 23, 23) var(--progress),
rgba(23, 23, 23) 100%
);
}
.range-slider::-webkit-slider-thumb {
appearance: none;
width: 2px;
height: 100%;
background: transparent;
border: none;
cursor: col-resize;
transition: all 0.2s ease;
}
.range-slider::-webkit-slider-thumb:hover {
background: rgba(255, 255, 255, 0);
width: 6px;
}
.range-slider::-moz-range-track {
height: 100%;
border-radius: 6px;
background: linear-gradient(
to right,
rgb(198, 198, 198) 0%,
rgba(198, 198, 198) var(--progress),
rgb(236, 236, 236) var(--progress),
rgba(236, 236, 236) 100%
);
backdrop-filter: blur(4px);
cursor: col-resize;
border: none;
}
.dark .range-slider::-moz-range-track {
background: linear-gradient(
to right,
rgba(151, 151, 151, 0.8) 0%,
rgba(151, 151, 151, 0.8) var(--progress),
rgba(23, 23, 23) var(--progress),
rgba(23, 23, 23) 100%
);
}
.range-slider::-moz-range-thumb {
width: 2px;
height: 100%;
background: transparent;
border: none;
border-radius: 0;
cursor: col-resize;
transition: all 0.2s ease;
}
.range-slider::-moz-range-thumb:hover {
background: rgba(255, 255, 255, 0);
width: 6px;
}
.range-slider:disabled {
opacity: 0.5;
pointer-events: none;
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts" setup>
import { ref, watch } from 'vue'
interface AxisConfig {
min?: number
max?: number
step?: number
label?: string
formatValue?: (val: number) => string
precision?: number
}
const props = defineProps<{
label?: string
xConfig?: AxisConfig
yConfig?: AxisConfig
zConfig?: AxisConfig
}>()
const x = defineModel('x', { required: false, default: 0 })
const y = defineModel('y', { required: false, default: 0 })
const z = defineModel('z', { required: false, default: 0 })
// Dragging state
const isDragging = ref<'x' | 'y' | 'z' | undefined>()
const dragStartX = ref(0)
const dragStartValue = ref(0)
function postProcessValue(val: number, config?: AxisConfig): string | undefined {
if (config?.formatValue) {
return config.formatValue(val)
}
if (config?.precision) {
return val.toFixed(config.precision)
}
return val.toString()
}
const xNormalized = ref(postProcessValue(x.value, props.xConfig))
const yNormalized = ref(postProcessValue(y.value, props.yConfig))
const zNormalized = ref(postProcessValue(z.value, props.zConfig))
watch(x, () => xNormalized.value = postProcessValue(x.value, props.xConfig))
watch(y, () => yNormalized.value = postProcessValue(y.value, props.yConfig))
watch(z, () => zNormalized.value = postProcessValue(z.value, props.zConfig))
function handleChange(axis: 'x' | 'y' | 'z', event: Event) {
const input = event.target as HTMLInputElement
const value = Number.parseFloat(input.value)
if (Number.isNaN(value))
return
updateValue(axis, value)
}
function updateValue(axis: 'x' | 'y' | 'z', value: number) {
const config = axis === 'x' ? props.xConfig : axis === 'y' ? props.yConfig : props.zConfig
// Apply min/max constraints
if (config?.min !== undefined)
value = Math.max(config.min, value)
if (config?.max !== undefined)
value = Math.min(config.max, value)
switch (axis) {
case 'x':
x.value = value
xNormalized.value = postProcessValue(value, props.xConfig) || String(value)
break
case 'y':
y.value = value
yNormalized.value = postProcessValue(value, props.yConfig) || String(value)
break
case 'z':
z.value = value
zNormalized.value = postProcessValue(value, props.zConfig) || String(value)
break
}
}
function startDrag(axis: 'x' | 'y' | 'z', event: MouseEvent) {
event.preventDefault()
isDragging.value = axis
dragStartX.value = event.clientX
const currentValue = axis === 'x'
? x.value
: axis === 'y'
? y.value
: z.value
dragStartValue.value = currentValue
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(event: MouseEvent) {
if (!isDragging.value)
return
const deltaX = event.clientX - dragStartX.value
const config = isDragging.value === 'x'
? props.xConfig
: isDragging.value === 'y' ? props.yConfig : props.zConfig
const sensitivity = config?.step || 0.01
const newValue = dragStartValue.value + (deltaX * sensitivity)
updateValue(isDragging.value, newValue)
}
function stopDrag() {
isDragging.value = undefined
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
</script>
<template>
<div>
<slot name="label">
<span text-nowrap text-xs>{{ props.label || 'Point' }}</span>
</slot>
</div>
<div />
<label bg="neutral-100 dark:neutral-900" h-fit inline-flex items-center rounded-md px-2 py-1 border="~ 1 solid neutral-200 dark:neutral-800">
<span h-fit inline-flex items-center text-xs>
<span
cursor-col-resize select-none
:class="{ 'text-blue-500': isDragging === 'x' }"
@mousedown="(e) => startDrag('x', e)"
>|</span>
<input
:value="xNormalized"
type="number"
:min="props.xConfig?.min"
:max="props.xConfig?.max"
:step="0.0001"
max-w-4lh w-full appearance-none bg-transparent text-right font-mono outline-none
class="[&::-webkit-inner-spin-button]:(m-0 appearance-none)"
@change="(e) => handleChange('x', e)"
>
</span>
</label>
<label bg="neutral-100 dark:neutral-900" h-fit inline-flex items-center rounded-md px-2 py-1 border="~ 1 solid neutral-200 dark:neutral-800">
<span h-fit inline-flex items-center text-xs>
<span
cursor-col-resize select-none
:class="{ 'text-blue-500': isDragging === 'y' }"
@mousedown="(e) => startDrag('y', e)"
>|</span>
<input
:value="yNormalized"
type="number"
:min="props.yConfig?.min"
:max="props.yConfig?.max"
:step="0.0001"
max-w-4lh w-full appearance-none bg-transparent text-right font-mono outline-none
class="[&::-webkit-inner-spin-button]:(m-0 appearance-none)"
@change="(e) => handleChange('y', e)"
>
</span>
</label>
<label bg="neutral-100 dark:neutral-900" h-fit inline-flex items-center rounded-md px-2 py-1 border="~ 1 solid neutral-200 dark:neutral-800">
<span h-fit inline-flex items-center text-xs>
<span
cursor-col-resize select-none
:class="{ 'text-blue-500': isDragging === 'z' }"
@mousedown="(e) => startDrag('z', e)"
>|</span>
<input
:value="zNormalized"
type="number"
:min="props.zConfig?.min"
:max="props.zConfig?.max"
:step="0.0001"
max-w-4lh w-full appearance-none bg-transparent text-right font-mono outline-none
class="[&::-webkit-inner-spin-button]:(m-0 appearance-none)"
@change="(e) => handleChange('z', e)"
>
</span>
</label>
</template>
@@ -1,120 +0,0 @@
<script setup lang="ts">
import { computed, onUnmounted, 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
}>()
const sliderRef = ref<HTMLElement | null>(null)
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
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 roundToStep(value: number, step: number): number {
const precision = getStepPrecision(step)
const factor = 10 ** (precision + 3)
return Number.parseFloat((value * factor / factor).toFixed(precision))
}
const currentValue = computed(() => {
return roundToStep(clamp(props.modelValue, props.min, props.max), props.step)
})
const sliderStyle = computed(() => {
const percent = ((currentValue.value - props.min) / (props.max - props.min)) * 100
return {
width: `${percent}%`,
backgroundSize: `${percent}% 100%`,
}
})
function getNewValue(event: MouseEvent) {
if (!sliderRef.value)
return currentValue.value
const rect = sliderRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const percent = x / rect.width
const range = props.max - props.min
const value = props.min + (range * percent)
// Round to nearest step
const steps = Math.round(value / props.step) * props.step
return Math.min(Math.max(steps, props.min), props.max)
}
function handleMouseMove(event: MouseEvent) {
if (props.disabled)
return
const newValue = getNewValue(event)
emit('update:modelValue', newValue)
}
function handleMouseDown(event: MouseEvent) {
if (props.disabled)
return
event.preventDefault()
const newValue = getNewValue(event)
emit('update:modelValue', newValue)
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
function handleMouseUp(_: MouseEvent) {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
// Lifecycle hooks
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
})
</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"
>
<span :style="sliderStyle" bg="[#cabeff] dark:[#4e34b9]" relative block rounded-sm h="[14px]" />
<span
role="slider"
class="slider-thumb"
:style="{ left: `${((currentValue - min) / (max - min)) * 100}%` }"
absolute rounded-sm w="[1px]" h="[14px]" bg="neutral-100 dark:neutral-400" top="50%" transform="translate-x-[50%] translate-y-[-50%]"
/>
</span>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import Collapsable from '../Misc/Collapsable.vue'
withDefaults(defineProps<{
title?: string
icon?: string
innerClass?: string
expand?: boolean
}>(), {
expand: true,
})
</script>
<template>
<Collapsable :default="expand">
<template #trigger="slotProps">
<button
class="w-full flex items-center justify-between rounded-lg px-2 py-1 outline-none transition-all duration-250 ease-in-out"
text="neutral-600 dark:neutral-400 sm sm:base"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
@click="slotProps.setVisible(!slotProps.visible)"
>
<slot name="title">
<div flex items-center gap-1.5 text="xs 2xl:sm">
<div v-if="icon" :class="icon" size-4 />
{{ title }}
</div>
</slot>
<div
i-solar:alt-arrow-down-linear
transition="transform duration-250"
:class="{ 'rotate-180': slotProps.visible }"
/>
</button>
</template>
<div grid gap-2 p-1 :class="innerClass">
<slot />
</div>
</Collapsable>
</template>
@@ -1 +1,4 @@
export { default as Range } from './Range.vue'
export { default as Pane } from './Pane.vue'
export { default as PropertyNumber } from './PropertyNumber.vue'
export { default as PropertyPoint } from './PropertyPoint.vue'
export { default as Section } from './Section.vue'
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { FieldRange, Input } from '@proj-airi/ui'
import { Input } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { Vector3 } from 'three'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVRM } from '../../../../stores'
import { Callout, Section } from '../../../Layouts'
import { PropertyNumber, PropertyPoint, Section } from '../../../DataPane'
import { Callout } from '../../../Layouts'
import { Button } from '../../../Misc'
import { ColorPalette } from '../../../Widgets'
@@ -68,32 +68,52 @@ function urlUploadClick() {
loadingModel.value = true
localModelUrl.value = selectedModel.value
}
function resetCameraDistance() {
// Calculate the camera distance that can fit the up-2/3 part of the model in the view
const radians = (cameraFOV.value / 2 * Math.PI) / 180
const initialCameraOffset = new Vector3(
modelSize.value.x / 16,
modelSize.value.y / 6, // default y value
-(modelSize.value.y / 3) / Math.tan(radians), // default z value
)
cameraDistance.value = initialCameraOffset.length()
}
</script>
<template>
<Section
:title="t('settings.vrm.switch-to-vrm.title')"
icon="i-solar:magic-stick-3-bold-duotone"
:title="t('settings.pages.models.sections.section.scene')"
icon="i-solar:people-nearby-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button variant="secondary" @click="$emit('switchToLive2D')">
<Button variant="secondary" size="sm" @click="$emit('switchToLive2D')">
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
<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>
<div grid="~ cols-5 gap-1" p-2>
<PropertyPoint
v-model:x="modelOffset.x"
v-model:y="modelOffset.y"
v-model:z="modelOffset.z"
label="Model Position"
:x-config="{ min: -modelSize.x * 2, max: modelSize.x * 2, step: modelSize.x / 100, label: 'X', formatValue: val => val?.toFixed(4) }"
:y-config="{ min: -modelSize.y * 2, max: modelSize.y * 2, step: modelSize.y / 100, label: 'Y', formatValue: val => val?.toFixed(4) }"
:z-config="{ min: -modelSize.z * 2, max: modelSize.z * 2, step: modelSize.z / 100, label: 'Z', formatValue: val => val?.toFixed(4) }"
/>
<PropertyNumber
v-model="cameraFOV"
:config="{ min: 1, max: 180, step: 1, label: t('settings.vrm.scale-and-position.fov') }"
:label="t('settings.vrm.scale-and-position.fov')"
/>
<PropertyNumber
v-model="cameraDistance"
:config="{ min: modelSize.z, max: modelSize.z * 20, step: modelSize.z / 100, label: t('settings.vrm.scale-and-position.camera-distance') }"
:label="t('settings.vrm.scale-and-position.camera-distance')"
/>
<PropertyNumber
v-model="modelRotationY"
:config="{ min: -180, max: 180, step: 1, label: t('settings.vrm.scale-and-position.rotation-y') }"
:label="t('settings.vrm.scale-and-position.rotation-y')"
/>
</div>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
@@ -124,31 +144,7 @@ function resetCameraDistance() {
{{ 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"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
<div>
<div class="text-sm text-neutral-600 space-y-1">
@@ -175,115 +171,5 @@ function resetCameraDistance() {
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
v-model="modelOffset.x"
as="div"
: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)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.x') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.y"
as="div"
: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)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.z"
as="div"
: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)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<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
v-model="cameraDistance"
as="div"
:min="modelSize.z"
:max="modelSize.z * 20"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.camera-distance')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.camera-distance') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="resetCameraDistance">
<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>
@@ -121,8 +121,8 @@ defineExpose({
<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" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.8" :position="[1, 1, -10]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.2" />
<OrbitControls ref="controlsRef" />
<VRMModel
ref="modelRef"
@@ -130,8 +130,10 @@ export function useIdleEyeSaccades() {
if (!vrm.lookAt.target) {
vrm.lookAt.target = new Object3D()
}
vrm.lookAt.target.position.lerp(fixationTarget!, randFloat(0.2, 0.5))
vrm.lookAt?.update(delta)
timeSinceLastSaccade += delta
}