Browse Source

3D模型查看器

master
修宁 7 months ago
parent
commit
bd7d2b0b04
  1. 99
      src/components/LoadingDialog.vue
  2. 279
      src/designer/Model3DView.vue
  3. 37
      src/runtime/System.ts

99
src/components/LoadingDialog.vue

@ -0,0 +1,99 @@
<template>
<el-dialog v-model="isShow" @closed="onClosed" append-to-body
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false" class="yvan-loading">
<div class="yvan-loading">
<el-icon class="is-loading">
<Loading/>
</el-icon>
<div class="yvan-loading-wait">{{ loadingMsg }}</div>
</div>
</el-dialog>
</template>
<script>
import _ from "lodash";
import {Loading} from '@element-plus/icons-vue'
export default {
props: [
'_insId',
'msg',
],
components: {
Loading
},
data() {
return {
isShow: false,
}
},
mounted() {
this.isShow = true
},
computed: {
loadingMsg() {
const msg = this.$props.msg
if (msg) {
const msgLocale = msg
if (msgLocale) {
return msgLocale
}
return msg
} else {
return '载入中...'
}
}
},
methods: {
onClosed() {
const {rootElementList} = system
console.log(this.$props._insId, rootElementList.map(r => r))
_.remove(rootElementList, (item) => item.props._insId === this.$props._insId)
},
}
}
</script>
<style lang="less">
.el-dialog.yvan-loading {
background: none;
box-shadow: none;
& > .el-dialog__header {
padding: 0;
display: none;
}
& > .el-dialog__body {
background: none;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.yvan-loading {
color: #fff;
text-align: center;
width: 100px;
height: 100px;
background: #000;
border-radius: 10px;
padding-top: 10px;
box-shadow: 3px 3px 3px rgba(0, 0, 0, .1);
.is-loading {
font-size: 50px;
}
.yvan-loading-wait {
font-size: 16px;
}
}
}
}
</style>

279
src/designer/Model3DView.vue

@ -1,9 +1,21 @@
<template>
<div class="model3d-view">
<div class="toolbar">
<input type="file" @change="handleFileUpload" accept=".fbx,.obj,.mtl" />
<span>文件上传</span>
</div>
<el-space :gutter="10" class="toolbar">
<el-upload :on-change="handleFileChange"
:show-file-list="false" accept=".fbx,.obj,.mtl,.3ds" action="" :auto-upload="false">
<el-button type="primary">上传文件</el-button>
</el-upload>
<el-upload :on-change="handleTextureUpload"
:show-file-list="false" accept=".png,.jpg,.jpeg" action="" :auto-upload="false"
list-type="picture">
<el-button>上传贴图</el-button>
</el-upload>
<!-- 1:1 / 1:0.001 比例 -->
<el-radio-group v-model="restate.loadScale" size="small">
<el-radio-button label="1:1" :value="1" />
<el-radio-button label="1:0.001" :value="0.001" />
</el-radio-group>
</el-space>
<div class="main-content">
<Split class="model3d-content" :direction="'horizontal'">
<SplitArea class="model3d-canvas" :size="70">
@ -19,12 +31,12 @@
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ref, onMounted, nextTick, reactive } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { TDSLoader } from 'three/examples/jsm/loaders/TDSLoader'
import * as dat from 'three/examples/jsm/libs/lil-gui.module.min.js'
import Stats from 'three/examples/jsm/libs/stats.module'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
@ -38,8 +50,11 @@ const guiPanel = ref(null)
// Three.js
let scene, camera, renderer, controls
let statsControls, axesHelper, gridHelper
let modelGroup = new THREE.Group()
let gui, transformControls
let gui, tcontrols, modelGroup
const restate = reactive({
loadScale: 1
})
//
const state = {
@ -101,37 +116,30 @@ function initThree() {
//
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(10, 10, 10)
scene.add(directionalLight)
// const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
// directionalLight.position.set(10, 10, 10)
// scene.add(directionalLight)
//
statsControls = new Stats()
statsControls.dom.style.position = 'absolute'
viewerDom.appendChild(statsControls.dom)
// TransformControls
transformControls = new TransformControls(camera, renderer.domElement)
transformControls.attach(modelGroup)
animate()
//
transformControls.addEventListener('change', () => {
//
// TransformControls
tcontrols = new TransformControls(camera, renderer.domElement)
tcontrols.addEventListener('change', () => {
renderer.render(scene, camera)
reloadState()
})
transformControls.addEventListener('dragging-changed', function(event) {
//
tcontrols.addEventListener('dragging-changed', function(event) {
controls.enabled = !event.value
})
//
function animate() {
requestAnimationFrame(animate)
renderView()
}
animate()
tcontrols.setMode('translate')
// tcontrols.size = 1
// tcontrols.space = 'local'
scene.add(tcontrols.getHelper())
// ResizeObserver
const resizeObserver = new ResizeObserver(handleResize)
@ -139,6 +147,11 @@ function initThree() {
resizeObserver.observe(viewerDom)
}
//
function animate() {
requestAnimationFrame(animate)
renderView()
}
function initGUI() {
const guiDom = guiPanel.value
@ -146,6 +159,10 @@ function initGUI() {
gui = new dat.GUI({ autoPlace: false })
guiDom.appendChild(gui.domElement)
gui.add(state, 'mode', ['translate', 'rotate', 'scale']).onChange(function(value) {
tcontrols.setMode(value)
})
// 线
gui.add(state, 'showAxesHelper').name('显示坐标轴').onChange(val => {
axesHelper.visible = val
@ -166,6 +183,7 @@ function initGUI() {
cameraPosFolder.add(state.camera.position, 'z', -100, 100).step(0.1).listen().onChange(val => {
camera.position.z = val
})
cameraPosFolder.close()
// Rotation (in radians)
const cameraRotationFolder = gui.addFolder('摄像机旋转角')
@ -178,47 +196,47 @@ function initGUI() {
cameraRotationFolder.add(state.camera.rotation, 'z', -Math.PI, Math.PI).listen().onChange(val => {
camera.rotation.z = val
})
cameraRotationFolder.close()
// Position
const positionFolder = gui.addFolder('Position')
positionFolder.add(state.obj.position, 'x', -10, 10).onChange(val => {
positionFolder.add(state.obj.position, 'x', -10, 10).listen().onChange(val => {
modelGroup.position.x = val
})
positionFolder.add(state.obj.position, 'y', -10, 10).onChange(val => {
positionFolder.add(state.obj.position, 'y', -10, 10).listen().onChange(val => {
modelGroup.position.y = val
})
positionFolder.add(state.obj.position, 'z', -10, 10).onChange(val => {
positionFolder.add(state.obj.position, 'z', -10, 10).listen().onChange(val => {
modelGroup.position.z = val
})
positionFolder.close()
// Rotation (in radians)
const rotationFolder = gui.addFolder('Rotation')
rotationFolder.add(state.obj.rotation, 'x', -Math.PI, Math.PI).onChange(val => {
rotationFolder.add(state.obj.rotation, 'x', -Math.PI, Math.PI).listen().onChange(val => {
modelGroup.rotation.x = val
})
rotationFolder.add(state.obj.rotation, 'y', -Math.PI, Math.PI).onChange(val => {
rotationFolder.add(state.obj.rotation, 'y', -Math.PI, Math.PI).listen().onChange(val => {
modelGroup.rotation.y = val
})
rotationFolder.add(state.obj.rotation, 'z', -Math.PI, Math.PI).onChange(val => {
rotationFolder.add(state.obj.rotation, 'z', -Math.PI, Math.PI).listen().onChange(val => {
modelGroup.rotation.z = val
})
rotationFolder.close()
// Scale
const scaleFolder = gui.addFolder('Scale')
scaleFolder.add(state.obj.scale, 'x', 0.001, 10).onChange(val => {
scaleFolder.add(state.obj.scale, 'x', 0.001, 10).listen().onChange(val => {
modelGroup.scale.x = val
})
scaleFolder.add(state.obj.scale, 'y', 0.001, 10).onChange(val => {
scaleFolder.add(state.obj.scale, 'y', 0.001, 10).listen().onChange(val => {
modelGroup.scale.y = val
})
scaleFolder.add(state.obj.scale, 'z', 0.001, 10).onChange(val => {
scaleFolder.add(state.obj.scale, 'z', 0.001, 10).listen().onChange(val => {
modelGroup.scale.z = val
})
gui.add(state, 'mode', ['translate', 'rotate', 'scale']).onChange(function(value) {
transformControls.setMode(value)
})
scaleFolder.close()
//
// const cameraFolder = gui.addFolder('')
@ -284,35 +302,54 @@ function initMode3DCamera() {
//
// cameraNew.position.set(4, 2, -3);
// cameraNew.position.set(30, 30, 30)
cameraNew.position.set(5, 5, 5)
// 2.4,30,1.5;
cameraNew.position.set(2.4, 20, 1.5)
// cameraNew.position.set(2.4, 20, 1.5)
//
// cameraNew.lookAt(0, 0, 0)
cameraNew.lookAt(0, 0, 0)
// -1.57,0,1.57
cameraNew.rotation.set(-1.57, 0, 1.57)
// cameraNew.rotation.set(-1.57, 0, 1.57)
camera = cameraNew
scene.add(camera)
const controlsNew = new OrbitControls(
camera,
renderer?.domElement
)
const controlsNew = new OrbitControls(camera, viewerDom)
controlsNew.mouseButtons = { LEFT: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE } //
controlsNew.enableDamping = false
controlsNew.screenSpacePanning = false //
controlsNew.minDistance = 2
controls = controlsNew
controls.update()
camera.updateProjectionMatrix()
state.camera.position.x = camera.position.x
state.camera.position.y = camera.position.y
state.camera.position.z = camera.position.z
reloadState()
}
state.camera.rotation.x = camera.rotation.x
state.camera.rotation.y = camera.rotation.y
state.camera.rotation.z = camera.rotation.z
function reloadState() {
if (camera) {
state.camera.position.x = camera.position.x
state.camera.position.y = camera.position.y
state.camera.position.z = camera.position.z
state.camera.rotation.x = camera.rotation.x
state.camera.rotation.y = camera.rotation.y
state.camera.rotation.z = camera.rotation.z
}
if (modelGroup) {
state.obj.position.x = modelGroup.position.x
state.obj.position.y = modelGroup.position.y
state.obj.position.z = modelGroup.position.z
state.obj.rotation.x = modelGroup.rotation.x
state.obj.rotation.y = modelGroup.rotation.y
state.obj.rotation.z = modelGroup.rotation.z
state.obj.scale.x = modelGroup.scale.x
state.obj.scale.y = modelGroup.scale.y
state.obj.scale.z = modelGroup.scale.z
console.log('state.obj', state.obj)
}
}
function renderView() {
@ -331,45 +368,127 @@ function renderView() {
}
}
function handleTextureUpload(file) {
console.log('file', file)
if (!file) return
file = file.raw
const fileName = file.name.toLowerCase()
const reader = new FileReader()
reader.onerror = (error) => {
system.showErrorDialog('加载文件失败', error.toString())
system.clearLoading()
}
if (fileName.endsWith('.png') || fileName.endsWith('.jpg') || fileName.endsWith('.jpeg')) {
reader.onload = () => {
// const textureLoader = new THREE.TextureLoader()
// const texture = textureLoader.load(reader.result)
// tcontrols.setTexture(texture)
// system.clearLoading()
const texture = new THREE.TextureLoader().load(reader.result)
//
modelGroup.traverse(function(child) {
if (child.isMesh) {
child.material.map = texture
child.material.needsUpdate = true
}
})
}
reader.readAsDataURL(file)
} else {
alert('不支持的文件类型!')
}
}
function handleFileUpload(event) {
const file = event.target.files[0]
function handleFileChange(file) {
console.log('file', file)
if (!file) return
file = file.raw
//
if (modelGroup.children.length > 0) {
modelGroup.children.forEach(child => modelGroup.remove(child))
if (modelGroup) {
scene.remove(modelGroup)
}
const fileName = file.name.toLowerCase()
const reader = new FileReader()
reader.onerror = (error) => {
system.showErrorDialog('加载文件失败', error.toString())
system.clearLoading()
}
if (fileName.endsWith('.fbx')) {
reader.readAsArrayBuffer(file)
system.showLoading()
reader.onload = () => {
const loader = new FBXLoader()
const content = loader.parse(reader.result, '')
modelGroup.add(content)
const arrayBuffer = reader.result
const content = loader.parse(arrayBuffer, '')
modelGroup = content
if (restate.loadScale) {
modelGroup.scale.set(restate.loadScale, restate.loadScale, restate.loadScale)
state.obj.scale.x = modelGroup.scale.x
state.obj.scale.y = modelGroup.scale.y
state.obj.scale.z = modelGroup.scale.z
}
scene.add(modelGroup)
tcontrols.attach(modelGroup)
controls.target.copy(modelGroup.position)
controls.update()
system.clearLoading()
}
reader.readAsArrayBuffer(file)
} else if (fileName.endsWith('.obj')) {
reader.readAsText(file)
reader.onload = () => {
const loader = new OBJLoader()
const content = loader.parse(reader.result)
modelGroup.add(content)
//@ts-ignore
modelGroup = loader.parse(reader.result)
if (restate.loadScale) {
modelGroup.scale.set(restate.loadScale, restate.loadScale, restate.loadScale)
state.obj.scale.x = modelGroup.scale.x
state.obj.scale.y = modelGroup.scale.y
state.obj.scale.z = modelGroup.scale.z
}
scene.add(modelGroup)
tcontrols.attach(modelGroup)
system.clearLoading()
}
} else if (fileName.endsWith('.3ds')) {
reader.onload = () => {
const loader = new TDSLoader()
const arrayBuffer = reader.result
//@ts-ignore
const content = loader.parse(arrayBuffer, '')
modelGroup = content
if (restate.loadScale) {
modelGroup.scale.set(restate.loadScale, restate.loadScale, restate.loadScale)
state.obj.scale.x = modelGroup.scale.x
state.obj.scale.y = modelGroup.scale.y
state.obj.scale.z = modelGroup.scale.z
}
scene.add(modelGroup)
tcontrols.attach(modelGroup)
system.clearLoading()
}
} else if (fileName.endsWith('.mtl')) {
alert('需要同时上传 .obj 和 .mtl 文件,请先实现多文件上传处理逻辑。')
reader.readAsArrayBuffer(file)
} else {
alert('不支持的文件类型!')
}
// modelGroup.position.set()
// modelGroup.rotation.set(0, 0, 0)
// modelGroup.scale.set(1, 1, 1)
}
window['state'] = state
</script>
<style scoped lang="less">
.model3d-view {
@ -378,23 +497,24 @@ function handleFileUpload(event) {
flex-grow: 1;
overflow: hidden;
.toolbar {
padding: 10px;
background: #f5f5f5;
border-bottom: 1px solid #ccc;
}
.dialog-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
padding: 10px;
background-color: #f0f0f0;
border-bottom: 1px solid #ccc;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
.model3d-content{
.model3d-content {
height: 100%;
display: flex;
flex-direction: row;
@ -403,7 +523,7 @@ function handleFileUpload(event) {
.canvas-container {
width: 100%;
height:100%;
height: 100%;
position: relative;
}
@ -411,9 +531,10 @@ function handleFileUpload(event) {
border-left: 1px solid #ccc;
background: #111;
width: 100%;
height:100%;
height: 100%;
overflow-y: auto;
:deep(.lil-gui.root){
:deep(.lil-gui.root) {
width: 100%;
}
}

37
src/runtime/System.ts

@ -9,6 +9,7 @@ import { QuestionFilled } from '@element-plus/icons-vue'
import { renderIcon } from '@/utils/webutils.ts'
import type { showDialogOption } from '@/SystemOption'
import ShowDialogWrap from '@/components/ShowDialogWrap.vue'
import LoadingDialog from '@/components/LoadingDialog.vue'
export default class System {
_ = _
@ -227,6 +228,42 @@ export default class System {
})
})
}
globalLoadingHandle = null // "正在载入..." 对话框
/**
* ...
*/
public showLoading(msg?: string): void {
if (this.globalLoadingHandle) {
// 存在 "正在载入..." 对话框
this.clearLoading()
}
const _insId = _.uniqueId('_dlg')
system.rootElementList.push({
cmp: markRaw(LoadingDialog),
props: {
_insId: _insId,
msg: msg
}
})
this.globalLoadingHandle = () => {
_.remove(system.rootElementList, (item: any) => item.props._insId === _insId)
}
}
/**
* ...
*/
public clearLoading(): void {
if (typeof this.globalLoadingHandle === 'function') {
this.globalLoadingHandle()
}
this.globalLoadingHandle = null
}
}
export interface ShowDialogOption {

Loading…
Cancel
Save