267 lines
7.2 KiB
Vue
267 lines
7.2 KiB
Vue
|
|
<!--
|
|||
|
|
* File: \src\components\Viewer.vue
|
|||
|
|
* Project: viewer_for_ruwei
|
|||
|
|
* File Created: Tuesday, 10th September 2024 10:44:34 am
|
|||
|
|
* Author: Sun Jie (j.sun@supreium.com)
|
|||
|
|
* -----
|
|||
|
|
* Last Modified: Tuesday, 10th September 2024 10:44:35 am
|
|||
|
|
* Modified By: Sun Jie (j.sun@supreium.com)
|
|||
|
|
* -----
|
|||
|
|
* Description: 3D 网格查看器组件,支持属性可视化切换
|
|||
|
|
* -----
|
|||
|
|
* Copyright (c) 2024 Supreium Co., Ltd , All Rights Reserved.
|
|||
|
|
-->
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="viewer-wrapper">
|
|||
|
|
<!-- 顶部属性切换工具栏 -->
|
|||
|
|
<div class="viewer-toolbar">
|
|||
|
|
<el-radio-group
|
|||
|
|
v-model="selected"
|
|||
|
|
size="small"
|
|||
|
|
class="property-toggle-group"
|
|||
|
|
v-if="properties && properties.length > 0"
|
|||
|
|
>
|
|||
|
|
<el-radio-button
|
|||
|
|
v-for="item in properties"
|
|||
|
|
:key="item"
|
|||
|
|
:value="item"
|
|||
|
|
>
|
|||
|
|
{{ item }}
|
|||
|
|
</el-radio-button>
|
|||
|
|
</el-radio-group>
|
|||
|
|
|
|||
|
|
<!-- 视图控制按钮 -->
|
|||
|
|
<div class="view-controls">
|
|||
|
|
<el-tooltip content="适应视图" placement="bottom">
|
|||
|
|
<button class="control-btn" @click="fitAll">
|
|||
|
|
<el-icon><FullScreen /></el-icon>
|
|||
|
|
</button>
|
|||
|
|
</el-tooltip>
|
|||
|
|
<el-tooltip content="等轴视图" placement="bottom">
|
|||
|
|
<button class="control-btn" @click="viewAt">
|
|||
|
|
<el-icon><View /></el-icon>
|
|||
|
|
</button>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 3D 渲染容器 -->
|
|||
|
|
<div class="viewer-container" ref="renderDiv">
|
|||
|
|
<!-- 加载状态指示器 -->
|
|||
|
|
<div v-if="isLoading" class="loading-overlay">
|
|||
|
|
<el-icon class="is-loading loading-icon"><Loading /></el-icon>
|
|||
|
|
<span class="loading-text">LOADING MODEL...</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { FullScreen, Loading, View } from '@element-plus/icons-vue';
|
|||
|
|
import axios from 'axios';
|
|||
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
|||
|
|
import { ViewHelper } from '../utils/viewHelper';
|
|||
|
|
|
|||
|
|
// 定义组件属性,默认使用 /example.json 作为数据源
|
|||
|
|
const props = withDefaults(defineProps<{
|
|||
|
|
dataPath?: string;
|
|||
|
|
}>(), {
|
|||
|
|
dataPath: '/example.json'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const renderDiv = ref<HTMLDivElement>();
|
|||
|
|
const resizeListener = ref<Function[]>([]);
|
|||
|
|
const destroyFunctions = ref<Function[]>([]);
|
|||
|
|
|
|||
|
|
const properties = ref<string[]>();
|
|||
|
|
const selected = ref<string>("");
|
|||
|
|
const isLoading = ref(true);
|
|||
|
|
|
|||
|
|
// 窗口尺寸调整回调
|
|||
|
|
const resizeCallback = () => resizeListener.value.forEach((callback) => callback());
|
|||
|
|
|
|||
|
|
// 监听容器尺寸变化
|
|||
|
|
const onDivResize = (currentDiv: HTMLDivElement, callback: ResizeObserverCallback) => {
|
|||
|
|
resizeListener.value.push(callback);
|
|||
|
|
const resizeObserver = new ResizeObserver(callback);
|
|||
|
|
resizeObserver.observe(currentDiv!);
|
|||
|
|
destroyFunctions.value.push(() => resizeObserver?.disconnect());
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始化视图
|
|||
|
|
const init = async () => {
|
|||
|
|
window.addEventListener("resize", resizeCallback);
|
|||
|
|
await ViewHelper.instance.initView(renderDiv.value!);
|
|||
|
|
onDivResize(renderDiv.value!, () => ViewHelper.instance.viewResize(renderDiv.value!.clientWidth, renderDiv.value!.clientHeight));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await init();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 加载网格数据
|
|||
|
|
const mesh = (await axios.get(props.dataPath)).data;
|
|||
|
|
// 创建视图对象
|
|||
|
|
ViewHelper.instance.loadMesh(mesh.mesh, mesh.properties);
|
|||
|
|
/**
|
|||
|
|
* 如需过滤属性可使用:
|
|||
|
|
* ViewHelper.instance.loadMesh(mesh.mesh, (mesh.properties as MeshPropertyType[]).filter((item) => item.name === "vonMises" || item.name === "displacement-magnitude"));
|
|||
|
|
*/
|
|||
|
|
// 设置等轴视图并适应
|
|||
|
|
ViewHelper.instance.isometricView();
|
|||
|
|
ViewHelper.instance.fitAll();
|
|||
|
|
// 获取可用属性列表
|
|||
|
|
properties.value = ViewHelper.instance.properties;
|
|||
|
|
// 激活第一个属性,触发 watch
|
|||
|
|
if (properties.value && properties.value.length > 0) {
|
|||
|
|
selected.value = properties.value[0];
|
|||
|
|
}
|
|||
|
|
// 注册清理回调
|
|||
|
|
destroyFunctions.value.push(() => ViewHelper.instance.clear());
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载模型数据失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
isLoading.value = false;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
window.removeEventListener("resize", resizeCallback);
|
|||
|
|
destroyFunctions.value.forEach((fun) => fun());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 监听属性选择变化
|
|||
|
|
watch(selected, () => selected.value !== "" && ViewHelper.instance.activeProperty(selected.value))
|
|||
|
|
|
|||
|
|
// 适应视图
|
|||
|
|
const fitAll = () => ViewHelper.instance.fitAll();
|
|||
|
|
// 等轴视图
|
|||
|
|
const viewAt = () => ViewHelper.instance.isometricView();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="css" scoped>
|
|||
|
|
/* 查看器容器 */
|
|||
|
|
.viewer-wrapper {
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
background-color: #0b1120;
|
|||
|
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 工具栏 */
|
|||
|
|
.viewer-toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95) 0%, rgba(11, 17, 32, 0.9) 100%);
|
|||
|
|
border-bottom: 1px solid rgba(6, 182, 212, 0.15);
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 视图控制按钮组 */
|
|||
|
|
.view-controls {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
background: rgba(15, 23, 42, 0.8);
|
|||
|
|
border: 1px solid rgba(34, 211, 238, 0.3);
|
|||
|
|
color: #22d3ee;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn:hover {
|
|||
|
|
background: rgba(6, 182, 212, 0.2);
|
|||
|
|
box-shadow: 0 0 12px rgba(6, 182, 212, 0.4);
|
|||
|
|
transform: scale(1.05);
|
|||
|
|
border-color: #22d3ee;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn:active {
|
|||
|
|
transform: scale(0.95);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 3D 渲染区域 */
|
|||
|
|
.viewer-container {
|
|||
|
|
flex: 1;
|
|||
|
|
position: relative;
|
|||
|
|
background: radial-gradient(ellipse at center, #0f172a 0%, #0b1120 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 加载状态 */
|
|||
|
|
.loading-overlay {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: rgba(11, 17, 32, 0.9);
|
|||
|
|
backdrop-filter: blur(4px);
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-icon {
|
|||
|
|
font-size: 32px;
|
|||
|
|
color: #22d3ee;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-text {
|
|||
|
|
color: rgba(34, 211, 238, 0.8);
|
|||
|
|
font-size: 12px;
|
|||
|
|
letter-spacing: 0.2em;
|
|||
|
|
font-family: monospace;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 属性切换 Toggle Group 样式 */
|
|||
|
|
:deep(.property-toggle-group .el-radio-button__inner) {
|
|||
|
|
background-color: transparent;
|
|||
|
|
border: 1px solid rgba(34, 211, 238, 0.2);
|
|||
|
|
color: #94a3b8;
|
|||
|
|
font-family: monospace;
|
|||
|
|
font-size: 12px;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
padding: 6px 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.property-toggle-group .el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
|||
|
|
background-color: rgba(6, 182, 212, 0.15);
|
|||
|
|
border-color: #06b6d4;
|
|||
|
|
color: #22d3ee;
|
|||
|
|
box-shadow: 0 0 10px rgba(6, 182, 212, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.property-toggle-group .el-radio-button:first-child .el-radio-button__inner) {
|
|||
|
|
border-radius: 4px 0 0 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.property-toggle-group .el-radio-button:last-child .el-radio-button__inner) {
|
|||
|
|
border-radius: 0 4px 4px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.property-toggle-group .el-radio-button__inner:hover) {
|
|||
|
|
color: #22d3ee;
|
|||
|
|
background-color: rgba(34, 211, 238, 0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 处理单个按钮时的圆角 */
|
|||
|
|
:deep(.property-toggle-group .el-radio-button:only-child .el-radio-button__inner) {
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
</style>
|