[3d] use vue to rewrite the UI for load3d (#2467)

This commit is contained in:
Terry Jia
2025-02-09 12:05:42 -05:00
committed by GitHub
parent 91a3d1228e
commit 83cc49a42b
5 changed files with 273 additions and 345 deletions

View File

@@ -0,0 +1,117 @@
<template>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<Load3DControls
:backgroundColor="backgroundColor"
:showGrid="showGrid"
@toggleCamera="onToggleCamera"
@toggleGrid="onToggleGrid"
@updateBackgroundColor="onUpdateBackgroundColor"
ref="load3dControlsRef"
/>
<div
v-if="animations && animations.length > 0"
class="absolute top-0 left-0 w-full flex justify-center pt-2 gap-2 items-center pointer-events-auto z-10"
>
<Button class="p-button-rounded p-button-text" @click="togglePlay">
<i
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-white text-lg'
]"
></i>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
optionLabel="name"
optionValue="value"
@change="speedChange"
class="w-24"
/>
<Select
v-model="selectedAnimation"
:options="animations"
optionLabel="name"
optionValue="index"
@change="animationChange"
class="w-32"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
const props = defineProps<{
animations: Array<{ name: string; index: number }>
playing: boolean
backgroundColor: string
showGrid: boolean
}>()
const emit = defineEmits<{
(e: 'toggleCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'togglePlay', value: boolean): void
(e: 'speedChange', value: number): void
(e: 'animationChange', value: number): void
}>()
const animations = ref(props.animations)
const playing = ref(props.playing)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const load3dControlsRef = ref(null)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
{ name: '0.5x', value: 0.5 },
{ name: '1x', value: 1 },
{ name: '1.5x', value: 1.5 },
{ name: '2x', value: 2 }
]
watch(backgroundColor, (newValue) => {
load3dControlsRef.value.backgroundColor = newValue
})
const onToggleCamera = () => {
emit('toggleCamera')
}
const onToggleGrid = (value: boolean) => emit('toggleGrid', value)
const onUpdateBackgroundColor = (color: string) =>
emit('updateBackgroundColor', color)
const togglePlay = () => {
playing.value = !playing.value
emit('togglePlay', playing.value)
}
const speedChange = () => {
emit('speedChange', selectedSpeed.value)
}
const animationChange = () => {
emit('animationChange', selectedAnimation.value)
}
defineExpose({
animations,
selectedAnimation,
playing,
backgroundColor,
showGrid
})
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div
class="absolute top-2 left-2 flex flex-col gap-2 pointer-events-auto z-20"
>
<Button class="p-button-rounded p-button-text" @click="toggleCamera">
<i class="pi pi-camera text-white text-lg"></i>
</Button>
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
>
<i class="pi pi-table text-white text-lg"></i>
</Button>
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i class="pi pi-palette text-white text-lg"></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
const props = defineProps<{
backgroundColor: string
showGrid: boolean
}>()
const emit = defineEmits<{
(e: 'toggleCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const toggleCamera = () => {
emit('toggleCamera')
}
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
}
defineExpose({
backgroundColor,
showGrid
})
</script>

View File

@@ -281,7 +281,7 @@ app.registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 700)])
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 700)])
await nextTick()
@@ -415,7 +415,7 @@ app.registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)])
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()

View File

@@ -7,7 +7,9 @@ import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { App, createApp } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import { useToastStore } from '@/stores/toastStore'
class Load3d {
@@ -44,7 +46,9 @@ class Load3d {
cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
node: LGraphNode = {} as LGraphNode
bgColorInput: HTMLInputElement = {} as HTMLInputElement
protected controlsApp: App | null = null
protected controlsContainer: HTMLDivElement
constructor(container: Element | HTMLElement) {
this.scene = new THREE.Scene()
@@ -124,17 +128,39 @@ class Load3d {
this.createViewHelper(container)
this.createGridSwitcher(container)
this.controlsContainer = document.createElement('div')
this.controlsContainer.style.position = 'absolute'
this.controlsContainer.style.top = '0'
this.controlsContainer.style.left = '0'
this.controlsContainer.style.width = '100%'
this.controlsContainer.style.height = '100%'
this.controlsContainer.style.pointerEvents = 'none'
this.controlsContainer.style.zIndex = '1'
container.appendChild(this.controlsContainer)
this.createCameraSwitcher(container)
this.createColorPicker(container)
this.mountControls()
this.handleResize()
this.startAnimation()
}
protected mountControls() {
const controlsMount = document.createElement('div')
controlsMount.style.pointerEvents = 'auto'
this.controlsContainer.appendChild(controlsMount)
this.controlsApp = createApp(Load3DControls, {
backgroundColor: '#282828',
showGrid: true,
onToggleCamera: () => this.toggleCamera(),
onToggleGrid: (show: boolean) => this.toggleGrid(show),
onUpdateBackgroundColor: (color: string) => this.setBackgroundColor(color)
})
this.controlsApp.mount(controlsMount)
}
setNode(node: LGraphNode) {
this.node = node
}
@@ -184,145 +210,6 @@ class Load3d {
this.viewHelper.center = this.controls.target
}
createGridSwitcher(container: Element | HTMLElement) {
this.gridSwitcherContainer = document.createElement('div')
this.gridSwitcherContainer.style.position = 'absolute'
this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方
this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐
this.gridSwitcherContainer.style.width = '20px'
this.gridSwitcherContainer.style.height = '20px'
this.gridSwitcherContainer.style.cursor = 'pointer'
this.gridSwitcherContainer.style.alignItems = 'center'
this.gridSwitcherContainer.style.justifyContent = 'center'
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
const gridIcon = document.createElement('div')
gridIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h18v18H3z"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
`
const updateButtonState = () => {
if (this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
}
updateButtonState()
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
})
this.gridSwitcherContainer.title = 'Toggle Grid'
this.gridSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleGrid(!this.gridHelper.visible)
updateButtonState()
})
this.gridSwitcherContainer.appendChild(gridIcon)
container.appendChild(this.gridSwitcherContainer)
}
createCameraSwitcher(container: Element | HTMLElement) {
this.cameraSwitcherContainer = document.createElement('div')
this.cameraSwitcherContainer.style.position = 'absolute'
this.cameraSwitcherContainer.style.top = '3px'
this.cameraSwitcherContainer.style.left = '3px'
this.cameraSwitcherContainer.style.width = '20px'
this.cameraSwitcherContainer.style.height = '20px'
this.cameraSwitcherContainer.style.cursor = 'pointer'
this.cameraSwitcherContainer.style.alignItems = 'center'
this.cameraSwitcherContainer.style.justifyContent = 'center'
const cameraIcon = document.createElement('div')
cameraIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
<path d="m12 12 4-2.4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.cameraSwitcherContainer.title =
'Switch Camera (Perspective/Orthographic)'
this.cameraSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleCamera()
})
this.cameraSwitcherContainer.appendChild(cameraIcon)
container.appendChild(this.cameraSwitcherContainer)
}
createColorPicker(container: Element | HTMLElement) {
const colorPickerContainer = document.createElement('div')
colorPickerContainer.style.position = 'absolute'
colorPickerContainer.style.top = '53px'
colorPickerContainer.style.left = '3px'
colorPickerContainer.style.width = '20px'
colorPickerContainer.style.height = '20px'
colorPickerContainer.style.cursor = 'pointer'
colorPickerContainer.style.alignItems = 'center'
colorPickerContainer.style.justifyContent = 'center'
colorPickerContainer.title = 'Background Color'
const colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.opacity = '0'
colorInput.style.position = 'absolute'
colorInput.style.width = '100%'
colorInput.style.height = '100%'
colorInput.style.cursor = 'pointer'
const colorIcon = document.createElement('div')
colorIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M12 3v18"/>
<path d="M3 12h18"/>
</svg>
`
colorInput.addEventListener('input', (event) => {
const color = (event.target as HTMLInputElement).value
this.setBackgroundColor(color)
this.storeNodeProperty('Background Color', color)
})
this.bgColorInput = colorInput
colorPickerContainer.appendChild(colorInput)
colorPickerContainer.appendChild(colorIcon)
container.appendChild(colorPickerContainer)
}
setFOV(fov: number) {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.fov = fov
@@ -350,15 +237,6 @@ class Load3d {
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
//this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
@@ -725,6 +603,10 @@ class Load3d {
this.controls.dispose()
this.viewHelper.dispose()
this.renderer.dispose()
if (this.controlsApp) {
this.controlsApp.unmount()
this.controlsApp = null
}
this.renderer.domElement.remove()
this.scene.clear()
}
@@ -992,9 +874,11 @@ class Load3d {
this.renderer.setClearColor(new THREE.Color(color))
this.renderer.render(this.scene, this.activeCamera)
if (this.bgColorInput) {
this.bgColorInput.value = color
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.backgroundColor.value = color
}
this.storeNodeProperty('Background Color', color)
}
}

View File

@@ -1,5 +1,8 @@
import PrimeVue from 'primevue/config'
import * as THREE from 'three'
import { createApp } from 'vue'
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
class Load3dAnimation extends Load3d {
@@ -10,164 +13,47 @@ class Load3dAnimation extends Load3d {
isAnimationPlaying: boolean = false
animationSpeed: number = 1.0
playPauseContainer: HTMLDivElement = {} as HTMLDivElement
animationSelect: HTMLSelectElement = {} as HTMLSelectElement
speedSelect: HTMLSelectElement = {} as HTMLSelectElement
constructor(container: Element | HTMLElement) {
super(container)
this.createPlayPauseButton(container)
this.createAnimationList(container)
this.createSpeedSelect(container)
}
createAnimationList(container: Element | HTMLElement) {
this.animationSelect = document.createElement('select')
Object.assign(this.animationSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(15px)',
width: '90px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
protected mountControls() {
const controlsMount = document.createElement('div')
controlsMount.style.pointerEvents = 'auto'
this.controlsContainer.appendChild(controlsMount)
this.controlsApp = createApp(Load3DAnimationControls, {
backgroundColor: '#282828',
showGrid: true,
animations: [],
playing: false,
onToggleCamera: () => this.toggleCamera(),
onToggleGrid: (show: boolean) => this.toggleGrid(show),
onUpdateBackgroundColor: (color: string) =>
this.setBackgroundColor(color),
onTogglePlay: (play: boolean) => this.toggleAnimation(play),
onSpeedChange: (speed: number) => this.setAnimationSpeed(speed),
onAnimationChange: (selectedAnimation: number) =>
this.updateSelectedAnimation(selectedAnimation)
})
this.animationSelect.addEventListener('mouseenter', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.animationSelect.addEventListener('mouseleave', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.animationSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
this.updateSelectedAnimation(select.selectedIndex)
})
container.appendChild(this.animationSelect)
this.controlsApp.use(PrimeVue)
this.controlsApp.mount(controlsMount)
}
updateAnimationList() {
this.animationSelect.innerHTML = ''
this.animationClips.forEach((clip, index) => {
const option = document.createElement('option')
option.value = index.toString()
option.text = clip.name || `Animation ${index + 1}`
option.selected = index === this.selectedAnimationIndex
this.animationSelect.appendChild(option)
})
}
createPlayPauseButton(container: Element | HTMLElement) {
this.playPauseContainer = document.createElement('div')
this.playPauseContainer.style.position = 'absolute'
this.playPauseContainer.style.top = '3px'
this.playPauseContainer.style.left = '50%'
this.playPauseContainer.style.transform = 'translateX(-50%)'
this.playPauseContainer.style.width = '20px'
this.playPauseContainer.style.height = '20px'
this.playPauseContainer.style.cursor = 'pointer'
this.playPauseContainer.style.alignItems = 'center'
this.playPauseContainer.style.justifyContent = 'center'
const updateButtonState = () => {
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = `
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
`
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = `
<path d="M8 5v14l11-7z"/>
`
this.playPauseContainer.title = 'Play Animation'
}
if (this.controlsApp?._instance?.exposed) {
if (this.animationClips.length > 0) {
this.controlsApp._instance.exposed.animations.value =
this.animationClips.map((clip, index) => ({
name: clip.name || `Animation ${index + 1}`,
index
}))
} else {
this.controlsApp._instance.exposed.animations.value = []
}
}
const playIcon = document.createElement('div')
playIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M8 5v14l11-7z"/>
</svg>
`
this.playPauseContainer.addEventListener('mouseenter', () => {
this.playPauseContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.playPauseContainer.addEventListener('mouseleave', () => {
this.playPauseContainer.style.backgroundColor = 'transparent'
})
this.playPauseContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleAnimation()
updateButtonState()
})
this.playPauseContainer.appendChild(playIcon)
container.appendChild(this.playPauseContainer)
this.playPauseContainer.style.display = 'none'
}
createSpeedSelect(container: Element | HTMLElement) {
this.speedSelect = document.createElement('select')
Object.assign(this.speedSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(-75px)',
width: '60px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
const speeds = [0.1, 0.5, 1, 1.5, 2]
speeds.forEach((speed) => {
const option = document.createElement('option')
option.value = speed.toString()
option.text = `${speed}x`
option.selected = speed === 1
this.speedSelect.appendChild(option)
})
this.speedSelect.addEventListener('mouseenter', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.speedSelect.addEventListener('mouseleave', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.speedSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
const newSpeed = parseFloat(select.value)
this.setAnimationSpeed(newSpeed)
})
container.appendChild(this.speedSelect)
}
protected async setupModel(model: THREE.Object3D) {
@@ -200,22 +86,7 @@ class Load3dAnimation extends Load3d {
}
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
} else {
this.playPauseContainer.style.display = 'none'
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
this.animationSelect.style.display = 'block'
this.speedSelect.style.display = 'block'
this.updateAnimationList()
} else {
this.playPauseContainer.style.display = 'none'
this.animationSelect.style.display = 'none'
this.speedSelect.style.display = 'none'
}
this.updateAnimationList()
}
setAnimationSpeed(speed: number) {
@@ -261,7 +132,9 @@ class Load3dAnimation extends Load3d {
this.animationActions = [action]
this.updateAnimationList()
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.selectedAnimation.value = index
}
}
clearModel() {
@@ -277,23 +150,12 @@ class Load3dAnimation extends Load3d {
this.isAnimationPlaying = false
this.animationSpeed = 1.0
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.animations.value = []
this.controlsApp._instance.exposed.selectedAnimation.value = 0
}
super.clearModel()
if (this.animationSelect) {
this.animationSelect.style.display = 'none'
this.animationSelect.innerHTML = ''
}
if (this.speedSelect) {
this.speedSelect.style.display = 'none'
this.speedSelect.value = '1'
}
}
getAnimationNames(): string[] {
return this.animationClips.map((clip, index) => {
return clip.name || `Animation ${index + 1}`
})
}
toggleAnimation(play?: boolean) {
@@ -304,15 +166,8 @@ class Load3dAnimation extends Load3d {
this.isAnimationPlaying = play ?? !this.isAnimationPlaying
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = '<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>'
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = '<path d="M8 5v14l11-7z"/>'
this.playPauseContainer.title = 'Play Animation'
}
if (this.controlsApp?._instance?.exposed) {
this.controlsApp._instance.exposed.playing.value = this.isAnimationPlaying
}
this.animationActions.forEach((action) => {