feat(stage-ui): more data pane components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -113,6 +113,7 @@ words:
|
||||
- kirakira
|
||||
- kwaa
|
||||
- lemonnekogh
|
||||
- lerp
|
||||
- libsamplerate
|
||||
- libsodium
|
||||
- lightningcss
|
||||
|
||||
@@ -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: 切换角色的 Live2D,VRM 模型
|
||||
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>
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user