feat: 新增钢铁厂数字孪生平台基础架构和功能模块
- 添加Three.js基础场景配置和核心功能模块 - 实现模型加载器、动画循环和交互选择器 - 添加温度、压力等仪表盘组件 - 配置Vite构建工具和ESLint规范 - 添加基础UI组件和布局系统 - 实现数据可视化图表组件 - 配置Nginx部署文件 - 添加钢铁厂设备数据模型
23
apps/steelmill/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import Layout from './components/Layout.vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade"> <component :is="Component" /> </transition>
|
||||
</router-view>
|
||||
</Layout>
|
||||
</template>
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
BIN
apps/steelmill/src/assets/images/bg.jpg
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
apps/steelmill/src/assets/images/data.png
Normal file
|
After Width: | Height: | Size: 668 B |
BIN
apps/steelmill/src/assets/images/dialog-bg.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/steelmill/src/assets/images/head_bg.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
apps/steelmill/src/assets/images/top.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
apps/steelmill/src/assets/images/top@2x.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
10
apps/steelmill/src/assets/images/warn.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1558777632238" class="icon" style="" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" p-id="3002"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<defs>
|
||||
<style type="text/css"></style>
|
||||
</defs>
|
||||
<path d="M898.1 881.1h-772c-12.4 0-24-6.7-30.3-17.4-6.3-10.7-6.3-24.1-0.2-34.9l0.1-0.2 386-668.5c6.2-10.8 17.8-17.6 30.3-17.6s24.1 6.7 30.3 17.6l385.9 668.4c6.3 10.7 6.3 24.1 0.2 34.9-6.1 10.9-17.8 17.7-30.3 17.7z m-746.1-50h720L512 207.6 152 831.1z" p-id="3003" fill="#e93204"></path>
|
||||
<path d="M511.4 427.3c-19.2 0-34.6 15.9-33.9 35.1l5.6 168.1c0.5 15.3 13.1 27.4 28.4 27.4 15.3 0 27.8-12.1 28.4-27.4l5.6-168.1c0.5-19.2-14.9-35.1-34.1-35.1zM511.4 708.1c-9.3-0.1-18.2 3.5-24.7 10.1-6.7 6.3-10.5 15.2-10.4 24.5 0 9.8 3.4 18 10.4 24.6 6.6 6.5 15.5 10.1 24.7 9.9 9.2 0.1 18.2-3.4 24.7-9.9 6.8-6.4 10.6-15.3 10.4-24.6 0.1-9.2-3.6-18.1-10.4-24.5-6.5-6.6-15.4-10.3-24.7-10.1z" p-id="3004" fill="#e93204"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
6
apps/steelmill/src/assets/images/warn_fill.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1720740979431" class="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" p-id="4263"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48">
|
||||
<path d="M942.656 769.376 602.112 159.584c-22.144-39.712-55.104-62.496-90.304-62.496-35.232 0-68.16 22.784-90.368 62.528L81.312 769.344c-22.016 39.456-24.256 79.456-6.112 110.4C93.344 910.624 129.664 928 174.88 928l674.24 0c45.184 0 81.536-17.376 99.648-48.256C966.944 848.8 964.672 808.832 942.656 769.376zM480 320c0-17.664 14.336-32 32-32s32 14.336 32 32l0 288c0 17.696-14.336 32-32 32s-32-14.304-32-32L480 320zM512 832.128c-26.528 0-48-21.504-48-48s21.472-48 48-48 48 21.504 48 48S538.528 832.128 512 832.128z" p-id="4264" fill="#ffd700"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
1
apps/steelmill/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
78
apps/steelmill/src/components/Card.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div :class="['card-container', backgroud ? 'card-background' : '']">
|
||||
<div class="card-header">
|
||||
<div class="icon" v-if="!backgroud">
|
||||
<slot name="icon">
|
||||
<img src="@/assets/images/data.png" alt="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="title">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { VNode } from 'vue';
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
icon?: VNode;
|
||||
backgroud?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
icon: undefined,
|
||||
backgroud: false,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.card-container {
|
||||
position: relative;
|
||||
pointer-events: initial;
|
||||
backdrop-filter: blur(1px);
|
||||
border: 1px solid transparent;
|
||||
.card-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
font-size: 0.25rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
content: ' ';
|
||||
background-color: #f8fb10;
|
||||
}
|
||||
.icon {
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
// padding: 8px 12px;
|
||||
&.card-background {
|
||||
// background-color: rgb(7, 84, 140, 0.3);
|
||||
background-color: rgba(0, 34, 51, 0.4);
|
||||
.card-header {
|
||||
&::after {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
apps/steelmill/src/components/Layout.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<style lang="scss">
|
||||
.app-container {
|
||||
height: 100%;
|
||||
}
|
||||
:root {
|
||||
--app-header-height: 56px;
|
||||
--app-size--1x: 8px;
|
||||
--app-size--3x: 24px;
|
||||
--app-size--4x: calc(var(--app-size--1x) * 4);
|
||||
--app-icon--bg: transparent;
|
||||
--app-icon--size: 24px;
|
||||
--app-font-weight--medium: 500;
|
||||
--app-nav-item--width: auto;
|
||||
--app-color--black: #000;
|
||||
--app-color--blue10: #2e4994;
|
||||
--app-color--blue20: #3457b1;
|
||||
--app-color--blue30: #3e6ae1;
|
||||
--app-color--green: #12bb00;
|
||||
--app-color--grey10: #171a20;
|
||||
--app-color--grey15: #222;
|
||||
--app-color--grey20: #393c41;
|
||||
--app-color--grey25: #444;
|
||||
--app-color--grey30: #5c5e62;
|
||||
--app-color--grey35: #8e8e8e;
|
||||
--app-color--grey40: #a2a3a5;
|
||||
--app-color--grey45: #bbb;
|
||||
--app-color--grey50: #d0d1d2;
|
||||
--app-color--grey60: #e2e3e3;
|
||||
--app-color--grey65: #eee;
|
||||
--app-color--grey70: #f4f4f4;
|
||||
--app-color--red10: #b74134;
|
||||
--app-color--red20: #ed4e3b;
|
||||
--app-color--white: #fff;
|
||||
--app-color--yellow: #fbb01b;
|
||||
--app-text--white: #fff;
|
||||
--app-bezier: cubic-bezier(0.5, 0, 0, 0.75);
|
||||
}
|
||||
</style>
|
||||
29
apps/steelmill/src/components/Progress.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="progress">
|
||||
<div class="progress-inner">
|
||||
<el-progress :percentage="50" :indeterminate="true" :show-text="false"> </el-progress>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElProgress } from 'element-plus';
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgb(18, 19, 40);
|
||||
.progress-inner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 30%;
|
||||
font-size: 12px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
apps/steelmill/src/components/charts/BaseChart.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="chart-container" ref="chartDom"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { merge, cloneDeep } from 'lodash';
|
||||
const defaultOption = {
|
||||
// color: color,
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: {
|
||||
// color: '#4E5969',
|
||||
fontSize: 12,
|
||||
},
|
||||
title: {
|
||||
padding: 5,
|
||||
left: 0,
|
||||
top: 0,
|
||||
textStyle: {
|
||||
fontSize: '14',
|
||||
fontWeight: 'normal',
|
||||
color: '#464646',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemHeight: 12,
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
},
|
||||
tooltip: {},
|
||||
},
|
||||
};
|
||||
const props = defineProps<{
|
||||
option: any;
|
||||
}>();
|
||||
const chartDom = ref<HTMLElement>();
|
||||
const chart = ref<echarts.ECharts>();
|
||||
// watchEffect(
|
||||
// () => {
|
||||
// // const mergedOption = merge(cloneDeep(defaultOption), props.option);
|
||||
// console.log('props.option', props.option);
|
||||
// updateChart();
|
||||
// },
|
||||
// { flush: 'post' }
|
||||
// );
|
||||
// watch(props.option, () => {
|
||||
// updateChart();
|
||||
// });
|
||||
|
||||
function initChart() {
|
||||
chart.value = echarts.init(chartDom.value);
|
||||
const mergedOption = merge(cloneDeep(defaultOption), props.option);
|
||||
|
||||
chart.value.setOption(mergedOption);
|
||||
}
|
||||
onMounted(() => {
|
||||
initChart();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
chart.value?.dispose();
|
||||
chart.value = undefined;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.chart-container {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
97
apps/steelmill/src/components/charts/Status.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="status-container" :class="cls">
|
||||
<div class="header" v-if="slots.title || title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</div>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
const props = defineProps<{ title?: string; status?: 'error' | 'success'; frame?: boolean }>();
|
||||
const cls = computed(() => {
|
||||
return [`status-${props.status || 'success'}`, props.frame ? 'status-container-frame' : ''];
|
||||
});
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.status-container {
|
||||
--body-text-color: #0ff37b;
|
||||
--border-frame-color: #2ae3f3;
|
||||
--background-color: rgba(7, 85, 140, 0.3);
|
||||
--boder-color: rgba(99, 145, 180, 0.6);
|
||||
--box-shadow-color: rgba(5, 157, 222, 0.25);
|
||||
--header-background-color: rgba(7, 85, 140, 0.5);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(1px);
|
||||
border: 1px solid var(--boder-color);
|
||||
box-shadow: inset 0 0 30px var(--box-shadow-color);
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.16rem;
|
||||
line-height: 0.3rem;
|
||||
background-color: var(--header-background-color);
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
display: block;
|
||||
content: ' ';
|
||||
}
|
||||
// &::before {
|
||||
// width: 13px;
|
||||
// height: 11px;
|
||||
// background: linear-gradient(45deg, #0785de, #dffaff);
|
||||
// }
|
||||
// &::after {
|
||||
// width: 13px;
|
||||
// height: 11px;
|
||||
// // background: linear-gradient(45deg, #0785de, #dffaff) no-repeat;
|
||||
// background: linear-gradient(45deg, #0785de, #dffaff);
|
||||
// }
|
||||
}
|
||||
.body {
|
||||
padding: 12px;
|
||||
font-size: 0.2rem;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
&.status-success {
|
||||
--body-text-color: #0ff37b;
|
||||
}
|
||||
&.status-error {
|
||||
--body-text-color: rgba(255, 5, 1, 1);
|
||||
--border-frame-color: rgba(255, 5, 5, 1);
|
||||
--background-color: rgba(255, 5, 1, 0.1);
|
||||
--boder-color: rgba(255, 5, 1, 0.3);
|
||||
--box-shadow-color: rgba(255, 5, 1, 0.3);
|
||||
--header-background-color: rgba(255, 5, 1, 0.2);
|
||||
}
|
||||
&.status-container-frame {
|
||||
background:
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -18px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -18px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -18px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -18px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -4px;
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
4px 22px,
|
||||
22px 4px;
|
||||
.header {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
apps/steelmill/src/hooks/useThree/Object3dWrap.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Mesh, Object3D } from 'three';
|
||||
|
||||
class Object3DWrap extends Mesh {
|
||||
public ancestors?: Object3D = undefined;
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export { Object3DWrap };
|
||||
77
apps/steelmill/src/hooks/useThree/core.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as THREE from 'three';
|
||||
import { CSS2DRenderer, OrbitControls } from 'three/examples/jsm/Addons.js';
|
||||
import Stats from 'three/addons/libs/stats.module.js';
|
||||
export function initScene() {
|
||||
const scene = new THREE.Scene();
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function initCamera(element: HTMLElement) {
|
||||
const fov = 45;
|
||||
const near = 0.1;
|
||||
const far = 2000;
|
||||
const aspect = element.clientWidth / element.clientHeight;
|
||||
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
|
||||
camera.position.set(0, 0, 0);
|
||||
return camera;
|
||||
}
|
||||
|
||||
export function initCSSRender(element: HTMLElement) {
|
||||
const CSSRenderer = new CSS2DRenderer();
|
||||
CSSRenderer.setSize(element.clientWidth, element.clientHeight);
|
||||
CSSRenderer.domElement.style.position = 'absolute';
|
||||
CSSRenderer.domElement.style.top = '0px';
|
||||
// CSSRenderer.domElement.style.pointerEvents = 'none';
|
||||
element.appendChild(CSSRenderer.domElement);
|
||||
return CSSRenderer;
|
||||
}
|
||||
|
||||
export function initRenderer(element: HTMLElement) {
|
||||
// @see https://threejs.org/docs/index.html#api/zh/renderers/WebGLRenderer
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true, //执行抗锯齿
|
||||
alpha: true, //设置背景色透明
|
||||
// precision:'highp',//色精度选择 highp/mediump/lowp
|
||||
});
|
||||
|
||||
renderer.shadowMap.enabled = true;
|
||||
|
||||
renderer.localClippingEnabled = true;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setSize(element.clientWidth, element.clientHeight);
|
||||
renderer.clearDepth();
|
||||
element.appendChild(renderer.domElement);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
export function initControls(camera: THREE.Camera, domElement: HTMLElement) {
|
||||
const controls = new OrbitControls(camera, domElement);
|
||||
controls.maxPolarAngle = THREE.MathUtils.degToRad(90);
|
||||
controls.enableDamping = true; //启用阻尼
|
||||
// controls.minDistance = 2;
|
||||
// controls.maxDistance = 100;
|
||||
controls.target.set(0, 0, 0);
|
||||
// controls.target.set(2, 12, 11);
|
||||
// controls.target.applyEuler(new THREE.Euler(-65, 15, 30));
|
||||
// controls.update();
|
||||
return controls;
|
||||
}
|
||||
|
||||
export function initStats(element: HTMLElement) {
|
||||
const stats = new Stats();
|
||||
stats.dom.style.position = 'absolute';
|
||||
element.appendChild(stats.dom);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function initLights(scene: THREE.Scene) {
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambient);
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(-4, 8, 4);
|
||||
scene.add(directionalLight);
|
||||
// const dhelper = new THREE.DirectionalLightHelper(directionalLight, 5, 0xff0000);
|
||||
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.7);
|
||||
hemisphereLight.position.set(0, 8, 0);
|
||||
// const hHelper = new THREE.HemisphereLightHelper(hemisphereLight, 5);
|
||||
}
|
||||
231
apps/steelmill/src/hooks/useThree/index.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import TWEEN, { Easing } from '@tweenjs/tween.js';
|
||||
import * as THREE from 'three';
|
||||
import { CSS2DRenderer } from 'three/examples/jsm/Addons.js';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
import * as ThreeBase from './core';
|
||||
import Loop, { type Updatable } from './loop';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
const events = <const>['click', 'dblclick', 'pointermove'];
|
||||
type EventMap = (typeof events)[number];
|
||||
|
||||
const useThree = () => {
|
||||
const containerEl = ref<HTMLElement>();
|
||||
const scene = shallowRef<THREE.Scene>();
|
||||
const camera = shallowRef<THREE.Camera>();
|
||||
const renderer = shallowRef<THREE.WebGLRenderer>();
|
||||
const cssRenderer = shallowRef<CSS2DRenderer>();
|
||||
|
||||
const controls = shallowRef<OrbitControls>();
|
||||
const mixers: THREE.AnimationMixer[] = [];
|
||||
const resizeListener: Array<() => void> = [];
|
||||
// let raycaster: THREE.Raycaster, pointer: THREE.Vector2;
|
||||
let loop: Loop;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setSize(containerEl.value!, camera.value!, renderer.value!, cssRenderer.value);
|
||||
resizeListener.forEach((func) => {
|
||||
func?.();
|
||||
});
|
||||
});
|
||||
const onResize = (callback: () => void) => {
|
||||
resizeListener.push(callback);
|
||||
};
|
||||
onMounted(() => {
|
||||
init();
|
||||
resizeObserver.observe(containerEl.value!);
|
||||
});
|
||||
const init = () => {
|
||||
const el = containerEl.value as HTMLElement;
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) {
|
||||
throw new Error('element should had width and height before init.');
|
||||
}
|
||||
scene.value = ThreeBase.initScene();
|
||||
camera.value = ThreeBase.initCamera(el);
|
||||
renderer.value = ThreeBase.initRenderer(el);
|
||||
cssRenderer.value = ThreeBase.initCSSRender(el);
|
||||
controls.value = ThreeBase.initControls(camera.value, cssRenderer.value!.domElement);
|
||||
loop = new Loop(camera.value, scene.value, renderer.value);
|
||||
};
|
||||
const setSize = (
|
||||
container: HTMLElement,
|
||||
camera: THREE.Camera,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
cssRenderer?: CSS2DRenderer
|
||||
) => {
|
||||
if (!container || !camera || !renderer || !cssRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientHeight, clientWidth } = container;
|
||||
if (clientHeight === 0 || clientWidth === 0) {
|
||||
throw new Error('element should had width and height before init.');
|
||||
}
|
||||
console.log('camera.type', camera.type);
|
||||
if (camera.type === 'PerspectiveCamera') {
|
||||
(camera as THREE.PerspectiveCamera).aspect = clientWidth / clientHeight;
|
||||
(camera as THREE.PerspectiveCamera).updateProjectionMatrix();
|
||||
}
|
||||
|
||||
renderer?.setSize(clientWidth, clientHeight);
|
||||
renderer?.setPixelRatio(window.devicePixelRatio);
|
||||
cssRenderer?.setSize(clientWidth, clientHeight);
|
||||
};
|
||||
|
||||
const render = (onUpdate?: Updatable['update']) => {
|
||||
loop.addUpdatables(controls.value!, {
|
||||
key: 'cssRenderer loop',
|
||||
update: () => {
|
||||
// console.log(`The last frame rendered in ${delta * 1000} milliseconds,run 'cssRenderer loop'`);
|
||||
cssRenderer.value!.render(scene.value!, camera.value!);
|
||||
},
|
||||
});
|
||||
loop.start((deltaTime) => {
|
||||
onUpdate?.(deltaTime);
|
||||
TWEEN.update();
|
||||
});
|
||||
};
|
||||
const focus = (target: THREE.Object3D) => {
|
||||
const pos = new THREE.Vector3();
|
||||
target.getWorldPosition(pos);
|
||||
// 相机飞行到的位置和观察目标拉开一定的距离
|
||||
const pos2 = pos.clone().addScalar(16);
|
||||
|
||||
// const box = new THREE.Box3();
|
||||
// const center = new THREE.Vector3();
|
||||
// const sphere = new THREE.Sphere();
|
||||
// const delta = new THREE.Vector3();
|
||||
// const quaternion = new THREE.Quaternion();
|
||||
|
||||
// box.setFromObject(target);
|
||||
// box.getCenter(center);
|
||||
// target.getWorldQuaternion(quaternion);
|
||||
|
||||
createCameraTween(pos2, pos);
|
||||
};
|
||||
|
||||
/**
|
||||
* 相机动画函数,从A点飞行到B点,A点表示相机当前所处状态
|
||||
* @param endPos {THREE.Vector3} 表示动画结束相机位置
|
||||
* @param endTarget {THREE.Vector3} 表示相机动画结束lookAt指向的目标观察点
|
||||
*/
|
||||
function createCameraTween(endPos: THREE.Vector3, endTarget: THREE.Vector3) {
|
||||
const cameraStartPos = camera.value!.position;
|
||||
const controlsPos = controls.value!.target;
|
||||
console.log('createCameraTween', cameraStartPos, controlsPos, endPos, endTarget);
|
||||
new TWEEN.Tween({
|
||||
// 不管相机此刻处于什么状态,直接读取当前的位置和目标观察点
|
||||
x: cameraStartPos.x,
|
||||
y: cameraStartPos.y,
|
||||
z: cameraStartPos.z,
|
||||
tx: controlsPos.x,
|
||||
ty: controlsPos.y,
|
||||
tz: controlsPos.z,
|
||||
})
|
||||
.to(
|
||||
{
|
||||
// 动画结束相机位置坐标
|
||||
x: endPos.x,
|
||||
y: endPos.y,
|
||||
z: endPos.z,
|
||||
// 动画结束相机指向的目标观察点
|
||||
tx: endTarget.x,
|
||||
ty: endTarget.y,
|
||||
tz: endTarget.z,
|
||||
},
|
||||
1000
|
||||
)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(function (obj) {
|
||||
// 动态改变相机位置
|
||||
camera.value!.position.set(obj.x, obj.y, obj.z);
|
||||
// 动态计算相机视线
|
||||
// camera.lookAt(obj.tx, obj.ty, obj.tz);
|
||||
controls.value!.target.set(obj.tx, obj.ty, obj.tz);
|
||||
controls.value!.update(); //内部会执行.lookAt()
|
||||
})
|
||||
.start();
|
||||
}
|
||||
const addEventListener = (
|
||||
eventName: EventMap,
|
||||
handler: (inersectObjects: THREE.Intersection[]) => void,
|
||||
objects?: THREE.Object3D<THREE.Object3DEventMap>[]
|
||||
) => {
|
||||
const throttleFunc = throttle((event: MouseEvent | PointerEvent) => {
|
||||
const px = event.offsetX;
|
||||
const py = event.offsetY;
|
||||
const { offsetWidth: width, offsetHeight: height } = renderer.value!.domElement!;
|
||||
const x = (px / width) * 2 - 1;
|
||||
const y = -(py / height) * 2 + 1;
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
pointer.set(x, y);
|
||||
raycaster.setFromCamera(pointer, camera.value!);
|
||||
if (!objects) {
|
||||
objects = [];
|
||||
}
|
||||
const intersects = raycaster.intersectObjects(objects, true);
|
||||
handler(intersects);
|
||||
}, 50);
|
||||
cssRenderer.value!.domElement.addEventListener<EventMap>(eventName, throttleFunc);
|
||||
};
|
||||
const dispose = () => {
|
||||
controls.value?.dispose();
|
||||
loop.stop();
|
||||
scene.value?.clear();
|
||||
renderer.value?.forceContextLoss();
|
||||
renderer.value?.dispose();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
onUnmounted(() => {
|
||||
dispose();
|
||||
});
|
||||
const onclick = (
|
||||
objects: THREE.Object3D<THREE.Object3DEventMap>[],
|
||||
handler: (inersectObjects: THREE.Intersection[]) => void
|
||||
) => {
|
||||
addEventListener('click', handler, objects);
|
||||
};
|
||||
/**
|
||||
* 加载模型动画
|
||||
* @param mesh
|
||||
* @param animations
|
||||
* @param animationName
|
||||
* @returns
|
||||
*/
|
||||
const loadAnimate = (
|
||||
mesh: THREE.Mesh | THREE.AnimationObjectGroup | THREE.Group,
|
||||
animations: Array<THREE.AnimationClip>,
|
||||
animationName: string
|
||||
) => {
|
||||
const mixer = new THREE.AnimationMixer(mesh);
|
||||
const clip = THREE.AnimationClip.findByName(animations, animationName);
|
||||
if (!clip) return undefined;
|
||||
const action = mixer.clipAction(clip);
|
||||
action.play();
|
||||
mixers.push(mixer);
|
||||
};
|
||||
|
||||
// const addStats = () => {};
|
||||
return {
|
||||
render,
|
||||
loadAnimate,
|
||||
scene,
|
||||
camera,
|
||||
renderer,
|
||||
controls,
|
||||
containerEl,
|
||||
cssRenderer,
|
||||
onclick,
|
||||
addEventListener,
|
||||
onResize,
|
||||
focus,
|
||||
};
|
||||
};
|
||||
export default useThree;
|
||||
|
||||
export { loadGLTF } from './modelLoader';
|
||||
export { initStats } from './core';
|
||||
export * from './utils';
|
||||
export * from './Object3dWrap';
|
||||
56
apps/steelmill/src/hooks/useThree/loop.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as THREE from 'three';
|
||||
export interface Updatable {
|
||||
key?: string;
|
||||
update(deltaTime: number): void;
|
||||
}
|
||||
const clock = new THREE.Clock();
|
||||
class Loop {
|
||||
private _updatables: Updatable[];
|
||||
camera: THREE.Camera;
|
||||
scene: THREE.Scene;
|
||||
renderer: THREE.WebGLRenderer;
|
||||
constructor(camera: THREE.Camera, scene: THREE.Scene, renderer: THREE.WebGLRenderer) {
|
||||
this.camera = camera;
|
||||
this.scene = scene;
|
||||
this.renderer = renderer;
|
||||
this._updatables = [];
|
||||
}
|
||||
get updatables(): Updatable[] {
|
||||
return this._updatables;
|
||||
}
|
||||
|
||||
addUpdatables(...objects: Updatable[]) {
|
||||
for (const o of objects) {
|
||||
if (!o.key) {
|
||||
o.key = Math.random().toString(36).slice(-8);
|
||||
}
|
||||
this.updatables.push(o);
|
||||
}
|
||||
}
|
||||
start(onUpdate?: Updatable['update']) {
|
||||
if (onUpdate) {
|
||||
this.addUpdatables({ key: 'start__onUpdate', update: onUpdate });
|
||||
}
|
||||
//start render loop
|
||||
this.renderer.setAnimationLoop(() => {
|
||||
// render a frame
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
// tell every animated object to tick forward one frame
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
stop() {
|
||||
this.renderer.setAnimationLoop(null);
|
||||
}
|
||||
private update() {
|
||||
// only call the getDelta function once per frame!
|
||||
const delta = clock.getDelta();
|
||||
|
||||
// console.log(`The last frame rendered in ${delta * 1000} milliseconds`);
|
||||
|
||||
for (const object of this.updatables) {
|
||||
object.update(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
export default Loop;
|
||||
29
apps/steelmill/src/hooks/useThree/modelLoader.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type GLTF, GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
||||
|
||||
const dracoloader = new DRACOLoader();
|
||||
dracoloader.setDecoderPath('/draco/');
|
||||
dracoloader.preload();
|
||||
/**
|
||||
* 加载gltf模型
|
||||
* @param url gltf resource URL
|
||||
*/
|
||||
export const loadGLTF = (
|
||||
url: string,
|
||||
onLoad: (data: GLTF) => void,
|
||||
onProgress?: (event: ProgressEvent<EventTarget>) => void,
|
||||
onError?: (err: unknown) => void
|
||||
) => {
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(dracoloader);
|
||||
loader.load(
|
||||
// resource URL
|
||||
url,
|
||||
// called when the resource is loaded
|
||||
onLoad,
|
||||
// called while loading is progressing
|
||||
onProgress,
|
||||
// called when loading has errors
|
||||
onError
|
||||
);
|
||||
};
|
||||
33
apps/steelmill/src/hooks/useThree/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as THREE from 'three';
|
||||
import TWEEN from '@tweenjs/tween.js';
|
||||
import { isFunction } from 'lodash';
|
||||
export function findParent(
|
||||
object3d: THREE.Object3D,
|
||||
predicate: (obj: THREE.Object3D) => boolean
|
||||
): THREE.Object3D | null {
|
||||
let parent: THREE.Object3D | null = object3d;
|
||||
while (!predicate(parent)) {
|
||||
parent = parent.parent;
|
||||
if (parent === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
export const animation = (props: {
|
||||
from: Record<string, any>;
|
||||
to: Record<string, any>;
|
||||
duration: number;
|
||||
easing?: any;
|
||||
onUpdate: (params: Record<string, any>) => void;
|
||||
onComplete?: (params: Record<string, any>) => void;
|
||||
}) => {
|
||||
const { from, to, duration, easing = TWEEN.Easing.Quadratic.Out, onUpdate, onComplete } = props;
|
||||
return new TWEEN.Tween(from)
|
||||
.to(to, duration)
|
||||
.easing(easing)
|
||||
.onUpdate((object) => isFunction(onUpdate) && onUpdate(object))
|
||||
.onComplete((object) => isFunction(onComplete) && onComplete(object))
|
||||
.start();
|
||||
};
|
||||
21
apps/steelmill/src/main.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue';
|
||||
import './style.css';
|
||||
import App from './App.vue';
|
||||
import route from './router';
|
||||
|
||||
createApp(App).use(route).mount('#app');
|
||||
|
||||
(function (doc, win) {
|
||||
const fn = () => {
|
||||
const docEl = doc.documentElement,
|
||||
clientWidth = docEl.clientWidth;
|
||||
if (!clientWidth) return;
|
||||
docEl.style.fontSize = 100 * (clientWidth / 1920) + 'px';
|
||||
if (clientWidth > 1920) {
|
||||
docEl.style.fontSize = '100px';
|
||||
}
|
||||
};
|
||||
if (!doc.addEventListener) return;
|
||||
win.addEventListener('resize', fn);
|
||||
doc.addEventListener('DOMContentLoaded', fn);
|
||||
})(document, window);
|
||||
19
apps/steelmill/src/pages/components/Header.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="header">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
font-size: 0.4rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: url(@/assets/images/top.png) no-repeat;
|
||||
background-image: image-set(url(@/assets/images/top.png) 1x, url(@/assets/images/top@2x.png) 2x);
|
||||
background-size: 100% 1rem;
|
||||
}
|
||||
</style>
|
||||
87
apps/steelmill/src/pages/components/LayoutScreen.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="layout-header">
|
||||
<div class="layout-header--container"><slot name="header" /></div>
|
||||
</div>
|
||||
<div class="layout-content">
|
||||
<div class="layout-content-left">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="layout-content-right">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
<div class="layout-content-main">
|
||||
<slot name="main" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="progress"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
// opacity: 0.4;
|
||||
|
||||
// background: #121328cc;
|
||||
background-image: url('@/assets/images/bg.jpg'),
|
||||
radial-gradient(circle at center, rgba(9, 49, 152, 0.706) 0%, rgba(19, 30, 58, 0.9) 100%),
|
||||
linear-gradient(
|
||||
to right,
|
||||
rgba(9, 49, 152, 0.8) 0%,
|
||||
rgba(9, 49, 152, 0.5) 10%,
|
||||
rgba(9, 49, 152, 0.1) 30%,
|
||||
rgba(9, 49, 152, 0) 50%,
|
||||
rgba(9, 49, 152, 0.1) 70%,
|
||||
rgba(9, 49, 152, 0.5) 90%,
|
||||
rgba(9, 49, 152, 0.8) 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
&-content {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
&-left {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
bottom: 0.2rem;
|
||||
left: 0.2rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
grid-gap: 10px;
|
||||
width: min(25vw, 480px);
|
||||
pointer-events: none;
|
||||
transition: all 0.5s cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||
// height: calc(100% - 1rem);
|
||||
}
|
||||
&-right {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 0.2rem;
|
||||
bottom: 0.2rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
grid-gap: 10px;
|
||||
justify-content: end;
|
||||
width: min(25vw, 480px);
|
||||
pointer-events: none;
|
||||
transition: all 0.5s cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||
}
|
||||
&-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
&-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
&--container {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
apps/steelmill/src/pages/components/Progress.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="progress" v-if="visible">
|
||||
<div class="progress-inner">
|
||||
<el-progress :percentage="percentage" color="#3CE6E6"> </el-progress>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElProgress } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
const percentage = ref<number>(0);
|
||||
const visible = ref<boolean>(false);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgb(18, 19, 40);
|
||||
.progress-inner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 30%;
|
||||
font-size: 12px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
apps/steelmill/src/pages/data/phoenix.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
function generateRandomNumber(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
// return Math.fround(Math.random() * (max - min)) + min;
|
||||
}
|
||||
function formatNumberPadZero(num: number) {
|
||||
const strNum = num.toString().padStart(2, '0');
|
||||
return strNum;
|
||||
}
|
||||
class BaseInfos<T = any> {
|
||||
protected count: number;
|
||||
protected equipmentList: Array<Record<string, T>>;
|
||||
constructor(count: number) {
|
||||
this.count = count;
|
||||
this.equipmentList = [];
|
||||
}
|
||||
get data() {
|
||||
return this.equipmentList;
|
||||
}
|
||||
update(): BaseInfos {
|
||||
return this;
|
||||
}
|
||||
isWarning(item: Record<string, any>) {
|
||||
for (const k in item) {
|
||||
if (item[k].value !== undefined && item[k].min !== undefined && item[k].max !== undefined) {
|
||||
if (item[k].value < item[k].min || item[k].value > item[k].max) {
|
||||
// console.log('----', k, item[k].value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export class AirCompressorInfos extends BaseInfos {
|
||||
constructor(count: number) {
|
||||
super(count);
|
||||
}
|
||||
update() {
|
||||
this.equipmentList = [];
|
||||
for (let i = 1; i <= this.count; i++) {
|
||||
const equipment = {
|
||||
name: `空压机_${formatNumberPadZero(i)}`,
|
||||
key: `kongyaji_${formatNumberPadZero(i)}`,
|
||||
排气压力: { value: generateRandomNumber(7, 14) / 10, unit: 'MPa', min: 0.7, max: 1.3 },
|
||||
第二级进口压力: { value: generateRandomNumber(7, 8) / 10, unit: 'Mpa', min: 0.7, max: 0.8 },
|
||||
第二级排放压力: { value: generateRandomNumber(7, 13) / 10, unit: 'Mpa', min: 0.7, max: 1.3 },
|
||||
轴承油压: { value: generateRandomNumber(1, 4) / 10, unit: 'Mpa', min: 0.1, max: 0.3 },
|
||||
第二级进口温度: { value: generateRandomNumber(34, 150), unit: '°C', min: 35, max: 150 },
|
||||
机组排放温度: { value: generateRandomNumber(75, 95), unit: '°C', min: 75, max: 95 },
|
||||
轴承油温: { value: generateRandomNumber(40, 60), unit: '°C', min: 40, max: 60 },
|
||||
第二级排放温度: { value: generateRandomNumber(70, 150), unit: '°C', min: 70, max: 150 },
|
||||
第一级排放温度: { value: generateRandomNumber(60, 80), unit: '°C', min: 60, max: 80 },
|
||||
};
|
||||
this.equipmentList.push(equipment);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
export class BlankingPressInfos extends BaseInfos {
|
||||
constructor(count: number) {
|
||||
super(count);
|
||||
}
|
||||
override update() {
|
||||
this.equipmentList = [];
|
||||
for (let i = 1; i <= this.count; i++) {
|
||||
const equipment = {
|
||||
name: `冲压机_${formatNumberPadZero(i)}`,
|
||||
key: `chongyaji_${formatNumberPadZero(i)}`,
|
||||
冲压次数: { value: generateRandomNumber(200, 1205), unit: '次/分钟', min: 200, max: 1200 },
|
||||
历史冲压次数: { value: generateRandomNumber(200, 1200), unit: '次/分钟', min: 200, max: 1200 },
|
||||
};
|
||||
this.equipmentList.push(equipment);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
export class InjectionMoldingMachineInfos extends BaseInfos {
|
||||
constructor(count: number) {
|
||||
super(count);
|
||||
}
|
||||
override update() {
|
||||
this.equipmentList = [];
|
||||
for (let i = 1; i <= this.count; i++) {
|
||||
const equipment = {
|
||||
name: `注塑机_${formatNumberPadZero(i)}`,
|
||||
key: `zhusuji_${formatNumberPadZero(i)}`,
|
||||
峰值压力: { value: generateRandomNumber(50, 150), unit: 'MPa', min: 50, max: 150 },
|
||||
最小缓冲: { value: generateRandomNumber(3, 6), unit: '次/分钟', min: 3, max: 5 },
|
||||
'V-P位置': { value: generateRandomNumber(3, 5), unit: '次/分钟', min: 3, max: 5 },
|
||||
'V-P压力': { value: generateRandomNumber(30, 102), unit: 'MPa', min: 30, max: 100 },
|
||||
};
|
||||
this.equipmentList.push(equipment);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
export class WavesolderingInfos extends BaseInfos {
|
||||
constructor(count: number) {
|
||||
super(count);
|
||||
}
|
||||
override update(): BaseInfos<any> {
|
||||
this.equipmentList = [];
|
||||
for (let i = 1; i <= this.count; i++) {
|
||||
const equipment = {
|
||||
name: `波峰焊_${formatNumberPadZero(i)}`,
|
||||
key: `bofenghan_${formatNumberPadZero(i)}`,
|
||||
顶部预热: { value: generateRandomNumber(90, 131), unit: '°C', min: 90, max: 130 },
|
||||
底部预热: { value: generateRandomNumber(90, 120), unit: '°C', min: 90, max: 120 },
|
||||
锡缸热桥: { value: generateRandomNumber(250, 265), unit: '°C', min: 250, max: 265 },
|
||||
氮气控制: { value: generateRandomNumber(100, 100), unit: '%', min: 99.99, max: 100 },
|
||||
热交换器: { value: generateRandomNumber(80, 280), unit: '°C', min: 80, max: 280 },
|
||||
高温计: { value: generateRandomNumber(89, 120), unit: '°C', min: 90, max: 120 },
|
||||
};
|
||||
this.equipmentList.push(equipment);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
// 冲压机:
|
||||
// 冲压次数(冲压次数告警)200-1200次/分钟
|
||||
// 历史冲压次数(历史冲压次数告警)
|
||||
|
||||
// 注塑机:
|
||||
// 峰值压力(峰值压力告警)50~150MPa
|
||||
// 最小缓冲(最小缓冲告警)3-5mm
|
||||
// V-P位置(V-P位置告警)
|
||||
// V-P压力(V-P压力告警)30~100 MPa
|
||||
|
||||
// 空压机:
|
||||
// 排气压力(排气压力告警)0.7~1.3 MPa
|
||||
// 第二级进口压力(第二级进口压力告警)0.7~0.8 MPa
|
||||
// 第二级排放压力(第二级排放压力告警)0.7 MPa~1.3 MPa
|
||||
// 轴承油压(轴承油压告警)0.1 MPa~0.3 MPa
|
||||
// 第二级进口温度(第二级进口温度告警)35℃~150℃
|
||||
// 机组排放温度(机组排放温度告警)75℃~95℃
|
||||
// 轴承油温(轴承油温告警)40°C~60°C
|
||||
// 第二级排放温度(第二级排放温度告警)70°C~150°C
|
||||
// 第一级排放温度(第一级排放温度告警)60℃~80C
|
||||
|
||||
// 波峰焊:
|
||||
// 顶部预热(顶部预热告警)90℃~130℃
|
||||
// 底部预热(底部预热告警)90°C到120°C
|
||||
// 锡缸热桥(锡缸热桥告警)250℃~265℃
|
||||
// 氮气控制(氮气控制告警)99.99%~100%
|
||||
// 热交换器(热交换器告警)80℃~280℃
|
||||
// 高温计(高温计告警)90°C~120°C
|
||||
118
apps/steelmill/src/pages/data/steelmill.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
class SteelmillEquipments {
|
||||
Jiarelu = {
|
||||
name: '加热炉',
|
||||
key: 'Jiarelu',
|
||||
温度: { value: '1335.00', unit: '°C' },
|
||||
// 燃料: { value: '31.8', unit: '' },
|
||||
加热速度: { value: '88', unit: '°C/s' },
|
||||
基准氧含量: { value: '5', unit: '%' },
|
||||
热负荷: { value: '300', unit: '°C' },
|
||||
};
|
||||
Tieshuiguan = {
|
||||
name: '铁水罐',
|
||||
key: 'TieShuiGuan',
|
||||
// 温度: { value: '1335.00', unit: '°C' },
|
||||
容量: { value: '150', unit: '吨' },
|
||||
周转率: { value: '80', unit: '%' },
|
||||
铁水温降: { value: '78', unit: '°C' },
|
||||
在线数量: { value: '5', unit: '' },
|
||||
};
|
||||
Zhuanlu = {
|
||||
name: '转炉',
|
||||
key: 'zhuan_lu',
|
||||
利用系数: { value: '38', unit: '吨/公称吨' },
|
||||
作业率: { value: '95', unit: '%' },
|
||||
倒命中率: { value: '80', unit: '%' },
|
||||
冶炼周期: { value: '35', unit: 'min' },
|
||||
};
|
||||
Dianhulu = {
|
||||
name: '电弧炉',
|
||||
key: 'Dianhulu',
|
||||
利用系数: { value: '28', unit: '吨/(MV.A·天)' },
|
||||
合格率: { value: '0.9', unit: '' },
|
||||
电力消耗: { value: '500', unit: 'kWh/t' },
|
||||
电极消耗: { value: '8.5', unit: 'kg/t' },
|
||||
};
|
||||
Lianzhu = {
|
||||
name: '连铸',
|
||||
key: 'Lianzhu',
|
||||
作业率: { value: '88', unit: '%' },
|
||||
铸坯合格率: { value: '99', unit: '%' },
|
||||
中间包使用寿命: { value: '1070', unit: 'min' },
|
||||
连浇炉数: { value: '4', unit: '' },
|
||||
};
|
||||
Zhuzhaji = {
|
||||
name: '主轧机',
|
||||
key: 'Zhuzhaji',
|
||||
平均小时产量: { value: '50', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Cuzhaji = {
|
||||
name: '粗轧机',
|
||||
key: 'Cuzhaji',
|
||||
平均小时产量: { value: '151', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Zhongzhaji = {
|
||||
name: '中轧机',
|
||||
key: 'Zhongzhaji',
|
||||
平均小时产量: { value: '151', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Jingzhaji = {
|
||||
name: '精轧机',
|
||||
key: 'Jingzhaji',
|
||||
平均小时产量: { value: '152', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99.9', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Duanqieji = {
|
||||
name: '端切机',
|
||||
key: 'Duanqieji',
|
||||
平均小时产量: { value: '151', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99.8', unit: '%' },
|
||||
成材率: { value: '99', unit: '%' },
|
||||
};
|
||||
Bianqieji = {
|
||||
name: '边切机',
|
||||
key: 'Bianqieji',
|
||||
平均小时产量: { value: '154', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Zhengpingji = {
|
||||
name: '整平机',
|
||||
key: 'Zhengpingji',
|
||||
平均小时产量: { value: '151', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Panjuanji = {
|
||||
name: '盘卷机',
|
||||
key: 'Panjuanji',
|
||||
平均小时产量: { value: '152', unit: '吨' },
|
||||
日历作业率: { value: '95', unit: '%' },
|
||||
钢材合格率: { value: '99', unit: '%' },
|
||||
成材率: { value: '98', unit: '%' },
|
||||
};
|
||||
Gaolu = {
|
||||
name: '高炉',
|
||||
key: 'GaoLu',
|
||||
温度: { value: '1590.00', unit: '°C' },
|
||||
压力: { value: '4.00', unit: 'Pa' },
|
||||
};
|
||||
}
|
||||
|
||||
const data = new SteelmillEquipments();
|
||||
|
||||
export default data;
|
||||
426
apps/steelmill/src/pages/steelmill2/Index.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<LayoutScreen>
|
||||
<template #header>
|
||||
<Header>
|
||||
<div class="title">钢铁厂数字孪生平台</div>
|
||||
</Header>
|
||||
</template>
|
||||
<template #left v-if="showSider">
|
||||
<div class="panels" :style="{ '--max-panel-width': `${maxPanelWidth}` }">
|
||||
<div class="status-list">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Gaolu.name"
|
||||
:key="equips.Gaolu.key"
|
||||
@click="handleClickStatus(equips.Gaolu.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div><Temperature title="温度" :max="2000" :value="Number(equips.Gaolu.温度.value)" /></div>
|
||||
<div><Pressure title="压力" :max="10" :value="Number(equips.Gaolu.压力.value)" /></div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Jiarelu.name"
|
||||
:key="equips.Jiarelu.key"
|
||||
@click="handleClickStatus(equips.Jiarelu.key)"
|
||||
>
|
||||
<div class="status-grid col-1">
|
||||
<div><Temperature title="温度" :max="2000" :value="Number(equips.Jiarelu.温度.value)" /></div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Tieshuiguan.name"
|
||||
:key="equips.Tieshuiguan.key"
|
||||
@click="handleClickStatus(equips.Tieshuiguan.key)"
|
||||
>
|
||||
<div class="status-grid">
|
||||
<div>
|
||||
<Statistic title="容量" :max="10" :value="equips.Tieshuiguan.容量.value">
|
||||
<template v-slot:suffix>{{ equips.Tieshuiguan.容量.unit }}</template>
|
||||
</Statistic>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Zhuanlu.name"
|
||||
:key="equips.Zhuanlu.key"
|
||||
@click="handleClickStatus(equips.Zhuanlu.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="利用系数" :max="2000" :value="equips.Zhuanlu.利用系数.value">
|
||||
<template v-slot:suffix>{{ equips.Zhuanlu.利用系数.unit }}</template>
|
||||
</Statistic>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Dianhulu.name"
|
||||
:key="equips.Dianhulu.key"
|
||||
@click="handleClickStatus(equips.Dianhulu.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="利用系数" :max="10" :value="equips.Dianhulu.利用系数.value"
|
||||
><template v-slot:suffix>{{ equips.Dianhulu.利用系数.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Lianzhu.name"
|
||||
:key="equips.Lianzhu.key"
|
||||
@click="handleClickStatus(equips.Lianzhu.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="作业率" :max="2000" :value="equips.Lianzhu.作业率.value"
|
||||
><template v-slot:suffix>{{ equips.Lianzhu.作业率.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Zhuzhaji.name"
|
||||
:key="equips.Zhuzhaji.key"
|
||||
@click="handleClickStatus(equips.Zhuzhaji.key)"
|
||||
>
|
||||
<div class="status-grid">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="2000" :value="equips.Zhuzhaji.平均小时产量.value"
|
||||
><template v-slot:suffix>{{ equips.Zhuzhaji.平均小时产量.unit }} </template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Cuzhaji.name"
|
||||
:key="equips.Cuzhaji.key"
|
||||
@click="handleClickStatus(equips.Cuzhaji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="10" :value="equips.Cuzhaji.平均小时产量.value"
|
||||
><template v-slot:suffix>{{ equips.Cuzhaji.平均小时产量.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Zhongzhaji.name"
|
||||
:key="equips.Zhongzhaji.key"
|
||||
@click="handleClickStatus(equips.Zhongzhaji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="2000" :value="equips.Zhongzhaji.平均小时产量.value">
|
||||
<template v-slot:suffix>{{ equips.Zhongzhaji.平均小时产量.unit }}</template>
|
||||
</Statistic>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Jingzhaji.name"
|
||||
:key="equips.Jingzhaji.key"
|
||||
@click="handleClickStatus(equips.Jingzhaji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="10" :value="equips.Jingzhaji.平均小时产量.value"
|
||||
><template v-slot:suffix>{{ equips.Jingzhaji.平均小时产量.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Duanqieji.name"
|
||||
:key="equips.Duanqieji.key"
|
||||
@click="handleClickStatus(equips.Duanqieji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="2000" :value="equips.Duanqieji.平均小时产量.value">
|
||||
<template v-slot:suffix>{{ equips.Duanqieji.平均小时产量.unit }}</template>
|
||||
</Statistic>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Bianqieji.name"
|
||||
:key="equips.Bianqieji.key"
|
||||
@click="handleClickStatus(equips.Bianqieji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="10" :value="equips.Bianqieji.平均小时产量.value"
|
||||
><template v-slot:suffix>{{ equips.Bianqieji.平均小时产量.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Zhengpingji.name"
|
||||
:key="equips.Zhengpingji.key"
|
||||
@click="handleClickStatus(equips.Zhengpingji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="2000" :value="equips.Zhengpingji.平均小时产量.value">
|
||||
<template v-slot:suffix>{{ equips.Zhengpingji.平均小时产量.unit }}</template>
|
||||
</Statistic>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
<div class="status-list col-2">
|
||||
<Status
|
||||
status="success"
|
||||
:frame="true"
|
||||
:title="equips.Panjuanji.name"
|
||||
:key="equips.Panjuanji.key"
|
||||
@click="handleClickStatus(equips.Panjuanji.key)"
|
||||
>
|
||||
<div class="status-grid col-2">
|
||||
<div>
|
||||
<Statistic title="平均小时产量" :max="10" :value="equips.Panjuanji.平均小时产量.value"
|
||||
><template v-slot:suffix>{{ equips.Panjuanji.平均小时产量.unit }}</template></Statistic
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Status>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #right v-if="showSider">
|
||||
<SceneRight />
|
||||
</template>
|
||||
<template #main>
|
||||
<Scene ref="sceneRef" />
|
||||
</template>
|
||||
</LayoutScreen>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import LayoutScreen from '@/pages/components/LayoutScreen.vue';
|
||||
import Header from '@/pages/components/Header.vue';
|
||||
import Status from './components/Status.vue';
|
||||
import SceneRight from './SceneRight.vue';
|
||||
import Scene from './Scene.vue';
|
||||
import { onMounted, provide, ref } from 'vue';
|
||||
import { injectContextKey } from './context';
|
||||
import signals from 'signals';
|
||||
import equips from '../data/steelmill';
|
||||
import Temperature from './components/Temperature.vue';
|
||||
import Pressure from './components/Pressure.vue';
|
||||
import Statistic from './components/Statistic.vue';
|
||||
const sceneRef = ref();
|
||||
const showSider = ref(true);
|
||||
const status = ref<any[]>([]);
|
||||
const maxPanelWidth = ref<string>('100%');
|
||||
const events: {
|
||||
focusTo: signals.Signal<any>;
|
||||
warn: signals.Signal<any>;
|
||||
} = {
|
||||
focusTo: new signals.Signal(),
|
||||
warn: new signals.Signal(),
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
status.value = [
|
||||
equips.Bianqieji,
|
||||
equips.Cuzhaji,
|
||||
equips.Dianhulu,
|
||||
equips.Duanqieji,
|
||||
equips.Gaolu,
|
||||
equips.Jiarelu,
|
||||
equips.Jingzhaji,
|
||||
equips.Lianzhu,
|
||||
equips.Panjuanji,
|
||||
equips.Tieshuiguan,
|
||||
equips.Zhengpingji,
|
||||
equips.Zhongzhaji,
|
||||
equips.Zhuanlu,
|
||||
equips.Zhuzhaji,
|
||||
];
|
||||
});
|
||||
const handleClickStatus = (name: string) => {
|
||||
console.log('focus to', name);
|
||||
dispatchFocus(name);
|
||||
};
|
||||
// const focusCallbacks: FocusToCallback[] = [];
|
||||
// const onFocusTo = (callback: FocusToCallback) => {
|
||||
// focusCallbacks.push(callback);
|
||||
// };
|
||||
const dispatchFocus = (focusKey: string) => {
|
||||
// for (const callback of focusCallbacks) {
|
||||
// callback?.(focusKey);
|
||||
// }
|
||||
events.focusTo.dispatch(focusKey);
|
||||
};
|
||||
// onMounted(() => {
|
||||
// // 设置panels 最大宽度
|
||||
// const width = document.body.clientWidth;
|
||||
// let panelWidth = width * 0.25;
|
||||
|
||||
// if (width > 1920) {
|
||||
// panelWidth = 1920 * 0.3;
|
||||
// }
|
||||
|
||||
// maxPanelWidth.value = `${panelWidth}px`;
|
||||
// // console.log('width', width, 'panelWidth', panelWidth, 'showSider', showSider.value);
|
||||
// });
|
||||
provide(injectContextKey, {
|
||||
events,
|
||||
status,
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: var(--max-panel-width, 100%);
|
||||
max-width: var(--max-panel-width, 100%);
|
||||
// grid-gap: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
scrollbar-width: none;
|
||||
> * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
background: linear-gradient(181deg, #8fe1ff 0%, #dffaff 99.31640625%);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.status-list {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
grid-gap: 0.1rem;
|
||||
align-items: center;
|
||||
&.col-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
&.row-3 {
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
}
|
||||
&.col-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
&.row-2 {
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
&.grid-auto-flow {
|
||||
grid-auto-flow: row dense;
|
||||
}
|
||||
.col-span-2 {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
}
|
||||
}
|
||||
.status-grid {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
// gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
& > div {
|
||||
flex: 1;
|
||||
// padding: 4px;
|
||||
width: 50%;
|
||||
}
|
||||
&.col-3 {
|
||||
> div {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
// display: grid;
|
||||
// grid-auto-rows: 1fr;
|
||||
// grid-gap: 0.15rem;
|
||||
// &.col-3 {
|
||||
// grid-template-columns: repeat(3, 1fr);
|
||||
// }
|
||||
// &.row-3 {
|
||||
// grid-template-rows: repeat(3, 1fr);
|
||||
// }
|
||||
// &.col-2 {
|
||||
// grid-template-columns: repeat(2, 1fr);
|
||||
// }
|
||||
// &.row-2 {
|
||||
// grid-template-rows: repeat(2, 1fr);
|
||||
// }
|
||||
}
|
||||
|
||||
.mask {
|
||||
--color-bg: #121328cc;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
transparent 0%,
|
||||
rgba(18, 19, 40, 0.1) 70%,
|
||||
rgba(18, 19, 40, 0.8) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
rgba(18, 19, 40, 0.8) 0%,
|
||||
rgba(18, 19, 40, 0.5) 10%,
|
||||
rgba(18, 19, 40, 0.1) 20%,
|
||||
rgba(18, 19, 40, 0) 50%,
|
||||
rgba(18, 19, 40, 0.1) 80%,
|
||||
rgba(18, 19, 40, 0.5) 90%,
|
||||
rgba(18, 19, 40, 0.8) 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
box-shadow: 0 0 0 9999em var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
476
apps/steelmill/src/pages/steelmill2/Scene.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div id="viewport" ref="viewport">
|
||||
<div class="progress" v-if="loading">
|
||||
<div class="progress-inner">
|
||||
<el-progress :percentage="50" :indeterminate="true" :show-text="false"> </el-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Player } from '@/three';
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
CSS2DObject,
|
||||
EffectComposer,
|
||||
OutlinePass,
|
||||
OutputPass,
|
||||
RenderPass,
|
||||
SMAAPass,
|
||||
RGBELoader,
|
||||
ProgressiveLightMap,
|
||||
} from 'three/examples/jsm/Addons.js';
|
||||
import { useContext } from './context';
|
||||
const player = new Player();
|
||||
const shadowMapRes = 512,
|
||||
lightMapRes = 1024,
|
||||
lightmapObjects: THREE.Object3D[] = [];
|
||||
let progressiveSurfacemap: ProgressiveLightMap;
|
||||
|
||||
const viewport = ref<HTMLElement>();
|
||||
let popoverObject: CSS2DObject;
|
||||
const selectedObjectInfo = ref<{ key?: string; [key: string]: unknown }>();
|
||||
|
||||
const context = useContext();
|
||||
const loading = ref<boolean>(true);
|
||||
const equipmentInfos = computed(() => {
|
||||
return context.status?.value || [];
|
||||
});
|
||||
let composer: EffectComposer, outlinePass: OutlinePass;
|
||||
const effectParams = {
|
||||
edgeStrength: 10.0,
|
||||
edgeGlow: 0.1,
|
||||
edgeThickness: 1,
|
||||
pulsePeriod: 3.0,
|
||||
rotate: false,
|
||||
usePatternTexture: false,
|
||||
visibleEdgeColor: '#d9af17', //'#E93204',
|
||||
hiddenEdgeColor: '#190a05',
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// intersectObjects = [];
|
||||
|
||||
player.setSize(viewport.value!.offsetWidth, viewport.value!.offsetHeight);
|
||||
player.setPixelRatio(window.devicePixelRatio);
|
||||
player.scene.fog = new THREE.Fog(0xcccccc, 120, 300);
|
||||
|
||||
viewport.value!.appendChild(player.dom);
|
||||
|
||||
context.events.focusTo.add(handleFocusTo);
|
||||
|
||||
player.addControls();
|
||||
|
||||
composer = new EffectComposer(player.renderer!);
|
||||
player.events.resize.add((width, height) => {
|
||||
composer.setSize(width, height);
|
||||
});
|
||||
|
||||
const renderPass = new RenderPass(player.scene!, player.camera!);
|
||||
composer.addPass(renderPass);
|
||||
//#region outlinePass
|
||||
outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(player.cavans.clientWidth, player.cavans.clientHeight),
|
||||
player.scene,
|
||||
player.camera!
|
||||
);
|
||||
outlinePass.visibleEdgeColor.set(effectParams.visibleEdgeColor);
|
||||
outlinePass.hiddenEdgeColor.set(effectParams.hiddenEdgeColor);
|
||||
outlinePass.pulsePeriod = effectParams.pulsePeriod;
|
||||
outlinePass.edgeGlow = effectParams.edgeGlow;
|
||||
outlinePass.edgeThickness = effectParams.edgeThickness;
|
||||
outlinePass.edgeStrength = effectParams.edgeStrength;
|
||||
composer.addPass(outlinePass);
|
||||
// width、height是canva画布的宽高度
|
||||
const { offsetWidth: width, offsetHeight: height } = player.cavans;
|
||||
|
||||
const outputPass = new OutputPass();
|
||||
|
||||
const pixelRatio = player.renderer!.getPixelRatio();
|
||||
|
||||
const smaaPass = new SMAAPass(width * pixelRatio, height * pixelRatio);
|
||||
composer.addPass(smaaPass);
|
||||
// 创建伽马校正通道
|
||||
// const gammaPass = new ShaderPass(GammaCorrectionShader);
|
||||
// composer.addPass(gammaPass);
|
||||
composer.addPass(outputPass);
|
||||
|
||||
player.events.start.add(() => {
|
||||
console.log('start run play ');
|
||||
});
|
||||
|
||||
player.events.objectSelected.add((obj: THREE.Object3D) => {
|
||||
if (obj) {
|
||||
// const selectObject = (obj as Object3DWrap).ancestors;
|
||||
onSelected(obj);
|
||||
}
|
||||
});
|
||||
loading.value = true;
|
||||
{
|
||||
const url = '/models/steelmill2/scene.glb';
|
||||
const loadmanager = THREE.DefaultLoadingManager;
|
||||
// loadmanager.onLoad=()
|
||||
const scene = await player.loader.loadFile(url, loadmanager);
|
||||
|
||||
scene.name = 'steelmill';
|
||||
progressiveSurfacemap = new ProgressiveLightMap(player.renderer!, lightMapRes);
|
||||
player.addObject(scene);
|
||||
}
|
||||
|
||||
// 加载hdr材质 并设置环境光贴图
|
||||
const rgbeLoader = new RGBELoader();
|
||||
const envMap = await rgbeLoader.loadAsync('/textures/skybox/industrial_sunset_02_puresky_4k.hdr ');
|
||||
envMap.mapping = THREE.EquirectangularReflectionMapping;
|
||||
// player.scene.background = envMap;
|
||||
player.scene.environment = envMap;
|
||||
|
||||
// 创建弹窗的css2d模型
|
||||
// popoverObject = new CSS2DObject(popoverRef.value!);
|
||||
|
||||
player.renderer!.toneMapping = THREE.NeutralToneMapping;
|
||||
player.renderer!.toneMappingExposure = 1;
|
||||
// 设置阴影
|
||||
player.enableShadows();
|
||||
|
||||
player.scene.traverse((item) => {
|
||||
// console.log('item-', item.name, item);
|
||||
if (item instanceof THREE.Mesh) {
|
||||
item.material.envMap = envMap;
|
||||
item.material.envMapIntensity = 0.8;
|
||||
// item.material.needsUpdate = true;
|
||||
lightmapObjects.push(item);
|
||||
progressiveSurfacemap.addObjectsToLightMap(lightmapObjects);
|
||||
} else {
|
||||
// item.layers.disableAll(); // Disable Rendering for this
|
||||
}
|
||||
});
|
||||
// player.scene.layers.set(1);
|
||||
player.scene.traverse((item) => {
|
||||
if (!(item instanceof THREE.Object3D)) {
|
||||
return;
|
||||
}
|
||||
if (canSelect(item)) {
|
||||
for (let i = 0; i < item.children.length; i++) {
|
||||
const group = item.children[i];
|
||||
//递归遍历chooseObj,并给chooseObj的所有子孙后代设置一个ancestors属性指向自己
|
||||
group.traverse(function (obj) {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
(obj as any).ancestors = item;
|
||||
}
|
||||
});
|
||||
}
|
||||
// intersectObjects.push(item);
|
||||
}
|
||||
});
|
||||
player.events.update.add((_timer) => {
|
||||
composer.render();
|
||||
// progressiveSurfacemap.update(player.camera!, 200, true);
|
||||
});
|
||||
{
|
||||
// 添加光照
|
||||
const directionalLight = new THREE.DirectionalLight(0xfdf5ed, 8);
|
||||
directionalLight.castShadow = true;
|
||||
// directionalLight.shadow.bias = -0.05;
|
||||
directionalLight.shadow.camera.left = -100;
|
||||
directionalLight.shadow.camera.right = 100;
|
||||
directionalLight.shadow.camera.top = 100;
|
||||
directionalLight.shadow.camera.bottom = -100;
|
||||
directionalLight.shadow.camera.near = 2;
|
||||
directionalLight.shadow.camera.far = 500;
|
||||
directionalLight.shadow.mapSize.set(shadowMapRes, shadowMapRes);
|
||||
directionalLight.shadow.radius = 2;
|
||||
player.scene.add(directionalLight);
|
||||
lightmapObjects.push(directionalLight);
|
||||
player.renderer!.shadowMap.type = THREE.VSMShadowMap;
|
||||
directionalLight.position.set(26, 48, 20);
|
||||
|
||||
// player.scene.add(new THREE.AmbientLight(0xfefefe, 1));
|
||||
player.scene.add(new THREE.HemisphereLight(0xffffff, 0x000000, 0.5));
|
||||
}
|
||||
|
||||
//
|
||||
//播放动画
|
||||
const model = player.scene.getObjectByName('steelmill')!;
|
||||
const animations = model.animations;
|
||||
for (const ani of animations) {
|
||||
player.addAnimation(animations, ani.name, model);
|
||||
}
|
||||
// player.controls?.addEventListener('change', (ev) => {
|
||||
// console.log('camera', ev.target.target, ev.target.object.position, player.camera!.position);
|
||||
// });
|
||||
player.controls!.maxDistance = 200;
|
||||
(player.camera! as THREE.PerspectiveCamera).near = 0.4;
|
||||
(player.camera! as THREE.PerspectiveCamera).far = 300;
|
||||
player.camera!.position.set(49.08655711956998, 15.013313129229896, 29.585290041126118);
|
||||
player.controls?.saveState();
|
||||
// const cameraHelper = new THREE.CameraHelper(player.camera!);
|
||||
// player.scene.add(cameraHelper);
|
||||
// if (process.env.NODE_ENV !== 'development') {
|
||||
// cameraHelper.visible = false;
|
||||
// }
|
||||
player.play();
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
function canSelect(object?: THREE.Object3D) {
|
||||
const intersectObjectsKey = equipmentInfos.value.map((v) => v.key);
|
||||
// equipmentInfos.value?.find((v) => v.key == object!.name || v.key == object?.userData.name);
|
||||
// if (object?.isObject3D) {
|
||||
// console.log('intersectObjectsKey', object?.userData.name);
|
||||
// }
|
||||
return object && intersectObjectsKey.includes(object?.userData.name);
|
||||
}
|
||||
let chooseObject: THREE.Object3D | null = null;
|
||||
function onSelected(object?: THREE.Object3D) {
|
||||
const selectObject = (object as any).ancestors || object;
|
||||
// console.log('selectObject', selectObject);
|
||||
|
||||
if (!selectObject || !canSelect(selectObject)) {
|
||||
outlinePass.selectedObjects = [];
|
||||
chooseObject?.remove(popoverObject);
|
||||
chooseObject = null;
|
||||
player.events.objectSelected.dispatch(null);
|
||||
context.events.objectSelected.dispatch(null);
|
||||
return;
|
||||
}
|
||||
chooseObject = selectObject;
|
||||
let current = equipmentInfos.value?.find((v) => v.key == selectObject.userData.name);
|
||||
|
||||
// console.log('outlinedObjects', outlinedObjects);
|
||||
outlinePass.selectedObjects = [selectObject];
|
||||
if (current && current.key !== 'GaoLu') {
|
||||
selectedObjectInfo.value = {
|
||||
...current,
|
||||
};
|
||||
context.events.objectSelected.dispatch(selectObject);
|
||||
}
|
||||
player.events.objectFocused.dispatch(selectObject);
|
||||
}
|
||||
const findObject = (key: string) => {
|
||||
let object = player.scene.getObjectByName(key);
|
||||
if (!object) {
|
||||
player.scene.traverse((child) => {
|
||||
if (child.userData.name === key) {
|
||||
object = child;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
return object;
|
||||
};
|
||||
const handleFocusTo = (focusKey: string) => {
|
||||
const object = findObject(focusKey);
|
||||
if (object) {
|
||||
onSelected(object);
|
||||
}
|
||||
};
|
||||
onUnmounted(() => {
|
||||
player?.dispose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
#viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgb(18, 19, 40);
|
||||
.progress-inner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 30%;
|
||||
font-size: 12px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
--background-color: rgba(45, 56, 85, 0.5);
|
||||
--boder-color: rgba(154, 166, 183, 0.5);
|
||||
--box-shadow-color: rgb(101, 219, 251, 0.38);
|
||||
--border-frame-color: rgb(40, 214, 140);
|
||||
position: absolute;
|
||||
// width: min(40vw, 916px);
|
||||
// height: min(calc(312 / 458 * 40vw), 624px);
|
||||
// pointer-events: none;
|
||||
// top: -150px;
|
||||
// left: 150px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
width: var(--width, 458px);
|
||||
height: var(--height, 312px);
|
||||
// background: url(@/assets/images/dialog-bg.png) no-repeat;
|
||||
// background-size: contain;
|
||||
transform: translate(-50%, -50%);
|
||||
.popover-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
// background-color: rgb(7, 84, 140, 0.3);
|
||||
flex: auto;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
padding: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
border: 2px solid var(--boder-color);
|
||||
box-shadow: inset 0 0 30px var(--box-shadow-color);
|
||||
}
|
||||
.frame {
|
||||
background:
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -4px;
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
4px 44px,
|
||||
44px 4px;
|
||||
box-shadow:
|
||||
// inset 0 0 10px 2px var(--box-shadow-color),
|
||||
inset 0 0 20px 10px var(--box-shadow-color),
|
||||
inset 0 0 40px 30px var(--box-shadow-color);
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: -0px;
|
||||
bottom: -0px;
|
||||
left: -0px;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
.popover-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
padding: 1px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background-image: linear-gradient(
|
||||
244deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgb(42, 227, 243) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
// bottom: 5px;
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
font-size: 18px;
|
||||
color: #dedede;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #fff;
|
||||
// text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
// padding: 12px;
|
||||
font-size: 20px;
|
||||
.canvas {
|
||||
flex: auto;
|
||||
max-width: 60%;
|
||||
}
|
||||
.detail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
font-size: 20px;
|
||||
color: #e0e0e0;
|
||||
label {
|
||||
margin-right: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
span {
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.warning {
|
||||
// position: absolute;
|
||||
// display: none;
|
||||
position: absolute;
|
||||
// width: 32px;
|
||||
// height: 32px;
|
||||
// display: flex;
|
||||
// top: 50%;
|
||||
// left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #e93204;
|
||||
pointer-events: none;
|
||||
animation-name: twinkle-key;
|
||||
animation-duration: 1s;
|
||||
animation-timing-function: ease-out;
|
||||
animation-iteration-count: infinite;
|
||||
.warning-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .twinkle {
|
||||
// animation-name: twinkle-key;
|
||||
// animation-duration: 1s;
|
||||
// animation-iteration-count: infinite;
|
||||
// }
|
||||
|
||||
@keyframes twinkle-key {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
apps/steelmill/src/pages/steelmill2/Scene2.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div id="popover-viewport" ref="viewport" :style="{ width: `${width}px`, height: `${height}px` }"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
|
||||
import { Player } from '@/three';
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
// CSS2DObject,
|
||||
EffectComposer,
|
||||
OutputPass,
|
||||
RenderPass,
|
||||
SMAAPass,
|
||||
RoomEnvironment,
|
||||
// ShaderPass,
|
||||
// GammaCorrectionShader,
|
||||
} from 'three/examples/jsm/Addons.js';
|
||||
import equips from '../data/steelmill';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
const equipmentInfos = ref([
|
||||
equips.Bianqieji,
|
||||
equips.Cuzhaji,
|
||||
equips.Dianhulu,
|
||||
equips.Duanqieji,
|
||||
// equips.Gaolu,
|
||||
equips.Jiarelu,
|
||||
equips.Jingzhaji,
|
||||
equips.Lianzhu,
|
||||
equips.Panjuanji,
|
||||
equips.Tieshuiguan,
|
||||
equips.Zhengpingji,
|
||||
equips.Zhongzhaji,
|
||||
equips.Zhuanlu,
|
||||
equips.Zhuzhaji,
|
||||
]);
|
||||
const modelNames = computed(() => {
|
||||
return equipmentInfos.value.map((v) => v.key).filter((v) => v !== 'GaoLu');
|
||||
});
|
||||
|
||||
let models: THREE.Group<THREE.Object3DEventMap>[] = [];
|
||||
|
||||
const player = new Player();
|
||||
const viewport = ref<HTMLElement>();
|
||||
|
||||
let composer: EffectComposer;
|
||||
let pmremGenerator: THREE.PMREMGenerator;
|
||||
const props = defineProps<{
|
||||
modelName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
visible?: boolean;
|
||||
// models: THREE.Group<THREE.Object3DEventMap>[];
|
||||
}>();
|
||||
|
||||
const { width: offsetWidth, height: offsetHeight } = useElementSize(viewport);
|
||||
watchEffect(() => {
|
||||
if (offsetWidth.value > 0 && offsetHeight.value > 0) {
|
||||
// console.log('setSize', offsetWidth.value, offsetHeight.value);
|
||||
// player.renderer?.clear();
|
||||
|
||||
player.setSize(offsetWidth.value, offsetHeight.value);
|
||||
}
|
||||
});
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.visible) {
|
||||
player.controls?.reset(false);
|
||||
visibleModel(props.modelName);
|
||||
player.play();
|
||||
} else {
|
||||
player.stop();
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
async function loadModels() {
|
||||
const tasks = modelNames.value.map((name) => async () => {
|
||||
const glb = await player.loader.loadFile(`/models/steelmill2/${name}.glb`);
|
||||
glb.name = name;
|
||||
glb.userData.name = name;
|
||||
return glb;
|
||||
});
|
||||
|
||||
models = await Promise.all(tasks.map((task) => task()));
|
||||
}
|
||||
function visibleModel(name?: string) {
|
||||
models.forEach((model) => {
|
||||
if (model.name === name) {
|
||||
// console.log('visible', name, model);
|
||||
|
||||
model.visible = true;
|
||||
const focusObject = player.scene.getObjectByName(props.modelName || '');
|
||||
let scalar = 1.5;
|
||||
// if (name === 'Dianhulu') {
|
||||
// scalar = 1.5;
|
||||
// }
|
||||
// if (name === 'Lianzhu') {
|
||||
// scalar = 1.5;
|
||||
// }
|
||||
// if (name === 'Zhongzhaji') {
|
||||
// scalar = 1.2;
|
||||
// }
|
||||
// if (name === 'Bianqieji') {
|
||||
// scalar = 1.6;
|
||||
// }
|
||||
// if (name === 'Zhengpingji') {
|
||||
// scalar = 1.5;
|
||||
// }
|
||||
// if (name === 'Cuzhaji') {
|
||||
// scalar = 1.2;
|
||||
// }
|
||||
// if (name === 'Jiarelu') {
|
||||
// scalar = 1.5;
|
||||
// }
|
||||
// if (name === 'zhuan_lu') {
|
||||
// scalar = 1.2;
|
||||
// }
|
||||
// if (name === 'Zhuzhaji') {
|
||||
// scalar = 1.2;
|
||||
// }
|
||||
// if (name === 'Jingzhaji') {
|
||||
// scalar = 1.4;
|
||||
// }
|
||||
// if (name === 'Duanqieji') {
|
||||
// scalar = 1.2;
|
||||
// }
|
||||
if (focusObject) {
|
||||
player.controls?.focus(focusObject, { scalar });
|
||||
}
|
||||
// player.controls?.reset();
|
||||
// player.controls?.focus2(model);
|
||||
} else {
|
||||
model.visible = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
onMounted(async () => {
|
||||
// console.log('viewport', viewport.value?.offsetHeight);
|
||||
player.setSize(props.width || viewport.value!.offsetWidth, props.height || viewport.value!.offsetHeight);
|
||||
player.setPixelRatio(window.devicePixelRatio);
|
||||
player.scene.fog = new THREE.Fog(0xcccccc, 2, 450);
|
||||
|
||||
viewport.value!.appendChild(player.dom);
|
||||
player.addControls();
|
||||
// const grid = new THREE.GridHelper(30, 15, 0xcccccc, 0x999999);
|
||||
// player.scene.add(grid);
|
||||
// ground
|
||||
// {
|
||||
// const ground = new THREE.Mesh(
|
||||
// new THREE.PlaneGeometry(30, 30, 1, 1),
|
||||
// new THREE.MeshPhongMaterial({ color: 0xa0adaf, shininess: 150 })
|
||||
// );
|
||||
|
||||
// ground.rotation.x = -Math.PI / 2; // rotates X/Y to X/Z
|
||||
// ground.receiveShadow = true;
|
||||
// player.scene.add(ground);
|
||||
// }
|
||||
// Lights
|
||||
{
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
|
||||
// ambientLight.layers.set(1);
|
||||
player.scene.add(ambientLight);
|
||||
|
||||
const spotLight = new THREE.SpotLight(0xffffff, 80);
|
||||
spotLight.angle = Math.PI / 4;
|
||||
spotLight.penumbra = 0.2;
|
||||
spotLight.position.set(0, 8, 3);
|
||||
spotLight.castShadow = false;
|
||||
spotLight.shadow.camera.near = 3;
|
||||
spotLight.shadow.camera.far = 30;
|
||||
spotLight.shadow.mapSize.width = 1024;
|
||||
spotLight.shadow.mapSize.height = 1024;
|
||||
// spotLight.layers.set(1);
|
||||
player.scene.add(spotLight);
|
||||
// player.addObject(new THREE.SpotLightHelper(spotLight));
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0x55505a, 4);
|
||||
dirLight.position.set(0, 8, 3);
|
||||
dirLight.castShadow = false;
|
||||
dirLight.shadow.camera.near = 1;
|
||||
dirLight.shadow.camera.far = 10;
|
||||
|
||||
dirLight.shadow.camera.right = 1;
|
||||
dirLight.shadow.camera.left = -1;
|
||||
dirLight.shadow.camera.top = 1;
|
||||
dirLight.shadow.camera.bottom = -1;
|
||||
|
||||
dirLight.shadow.mapSize.width = 1024;
|
||||
dirLight.shadow.mapSize.height = 1024;
|
||||
// dirLight.layers.set(1);
|
||||
player.scene.add(dirLight);
|
||||
}
|
||||
// composer
|
||||
{
|
||||
composer = new EffectComposer(player.renderer!);
|
||||
const renderPass = new RenderPass(player.scene!, player.camera!);
|
||||
composer.addPass(renderPass);
|
||||
const { offsetWidth: width, offsetHeight: height } = player.cavans;
|
||||
|
||||
const pixelRatio = player.renderer!.getPixelRatio();
|
||||
|
||||
const smaaPass = new SMAAPass(width * pixelRatio, height * pixelRatio);
|
||||
composer.addPass(smaaPass);
|
||||
// // 创建伽马校正通道
|
||||
// const gammaPass = new ShaderPass(GammaCorrectionShader);
|
||||
// composer.addPass(gammaPass);
|
||||
const outputPass = new OutputPass();
|
||||
composer.addPass(outputPass);
|
||||
}
|
||||
|
||||
await loadModels();
|
||||
models.forEach((model) => {
|
||||
model.scale.multiplyScalar(0.4);
|
||||
|
||||
player.addObject(model);
|
||||
if (['zhuan_lu', 'Jingzhaji'].includes(model.name)) {
|
||||
const animations = model.animations;
|
||||
for (const ani of animations) {
|
||||
player.addAnimation(animations, ani.name, model);
|
||||
}
|
||||
}
|
||||
// console.log('model', model.name, model);
|
||||
});
|
||||
// player.scene.traverse((item) => {
|
||||
// if (item.type === 'Group') {
|
||||
// console.log(item.name, item);
|
||||
// }
|
||||
// });
|
||||
|
||||
// player.controls?.addEventListener('change', (ev) => {
|
||||
// console.log('camera', ev.target.target, ev.target.object.position, player.camera!.position);
|
||||
// });
|
||||
player.controls!.autoRotate = true;
|
||||
player?.camera?.position.set(8.434432723983832, 9.526646692338861, 6.038920583677303);
|
||||
player.controls?.saveState();
|
||||
|
||||
player.enableShadows();
|
||||
visibleModel(props.modelName);
|
||||
// const focusObject = player.scene.getObjectByName(props.modelName || '');
|
||||
// if (focusObject) {
|
||||
// player.controls?.focus2(focusObject,);
|
||||
// }
|
||||
pmremGenerator = new THREE.PMREMGenerator(player.renderer!);
|
||||
pmremGenerator.compileEquirectangularShader();
|
||||
player.scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04, 0.01, 20).texture;
|
||||
player.events.resize.add((width, height) => {
|
||||
composer.setSize(width, height);
|
||||
});
|
||||
player.events.update.add((_timer) => {
|
||||
composer.render();
|
||||
});
|
||||
player.events.stop.add(() => {
|
||||
// console.log('player.events.stop in scene2.vue');
|
||||
});
|
||||
player.events.start.add(() => {
|
||||
// console.log('player.events.start in scene2.vue');
|
||||
});
|
||||
// player.play();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
pmremGenerator?.dispose();
|
||||
composer?.dispose();
|
||||
player.dispose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
#popover-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// backdrop-filter: blur(5px);
|
||||
}
|
||||
</style>
|
||||
384
apps/steelmill/src/pages/steelmill2/SceneRight.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div id="viewport">
|
||||
<div class="canvas">
|
||||
<scene2 :model-name="selectedObjectInfo?.key" :visible="showPopover"></scene2>
|
||||
</div>
|
||||
<div class="detail" v-if="selectedObjectInfo">
|
||||
<div v-for="prop in Object.keys(selectedObjectInfo)" :key="prop">
|
||||
<div v-if="prop !== 'name' && prop != 'key'">
|
||||
<label for="">{{ prop }}</label
|
||||
>{{ (selectedObjectInfo[prop] as any).value }}<span>{{ (selectedObjectInfo[prop] as any).unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Player } from '@/three';
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
CSS2DObject,
|
||||
EffectComposer,
|
||||
OutlinePass,
|
||||
OutputPass,
|
||||
RenderPass,
|
||||
SMAAPass,
|
||||
RGBELoader,
|
||||
ProgressiveLightMap,
|
||||
} from 'three/examples/jsm/Addons.js';
|
||||
import { useContext } from './context';
|
||||
import Scene2 from './Scene2.vue';
|
||||
const player = new Player();
|
||||
const shadowMapRes = 512,
|
||||
lightMapRes = 1024,
|
||||
lightmapObjects: THREE.Object3D[] = [];
|
||||
let progressiveSurfacemap: ProgressiveLightMap;
|
||||
|
||||
const showPopover = ref(false);
|
||||
let popoverObject: CSS2DObject;
|
||||
const selectedObjectInfo = ref<{ key?: string; [key: string]: unknown }>();
|
||||
|
||||
const context = useContext();
|
||||
const loading = ref<boolean>(true);
|
||||
const equipmentInfos = computed(() => {
|
||||
return context.status?.value || [];
|
||||
});
|
||||
let composer: EffectComposer, outlinePass: OutlinePass;
|
||||
const effectParams = {
|
||||
edgeStrength: 10.0,
|
||||
edgeGlow: 0.1,
|
||||
edgeThickness: 1,
|
||||
pulsePeriod: 3.0,
|
||||
rotate: false,
|
||||
usePatternTexture: false,
|
||||
visibleEdgeColor: '#d9af17', //'#E93204',
|
||||
hiddenEdgeColor: '#190a05',
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
player.setPixelRatio(window.devicePixelRatio);
|
||||
player.scene.fog = new THREE.Fog(0xcccccc, 120, 300);
|
||||
|
||||
context.events.focusTo.add(handleFocusTo);
|
||||
|
||||
player.addControls();
|
||||
|
||||
composer = new EffectComposer(player.renderer!);
|
||||
player.events.resize.add((width, height) => {
|
||||
composer.setSize(width, height);
|
||||
});
|
||||
|
||||
const renderPass = new RenderPass(player.scene!, player.camera!);
|
||||
composer.addPass(renderPass);
|
||||
//#region outlinePass
|
||||
outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(player.cavans.clientWidth, player.cavans.clientHeight),
|
||||
player.scene,
|
||||
player.camera!
|
||||
);
|
||||
outlinePass.visibleEdgeColor.set(effectParams.visibleEdgeColor);
|
||||
outlinePass.hiddenEdgeColor.set(effectParams.hiddenEdgeColor);
|
||||
outlinePass.pulsePeriod = effectParams.pulsePeriod;
|
||||
outlinePass.edgeGlow = effectParams.edgeGlow;
|
||||
outlinePass.edgeThickness = effectParams.edgeThickness;
|
||||
outlinePass.edgeStrength = effectParams.edgeStrength;
|
||||
composer.addPass(outlinePass);
|
||||
// width、height是canva画布的宽高度
|
||||
const { offsetWidth: width, offsetHeight: height } = player.cavans;
|
||||
|
||||
const outputPass = new OutputPass();
|
||||
|
||||
const pixelRatio = player.renderer!.getPixelRatio();
|
||||
|
||||
const smaaPass = new SMAAPass(width * pixelRatio, height * pixelRatio);
|
||||
composer.addPass(smaaPass);
|
||||
// 创建伽马校正通道
|
||||
// const gammaPass = new ShaderPass(GammaCorrectionShader);
|
||||
// composer.addPass(gammaPass);
|
||||
composer.addPass(outputPass);
|
||||
|
||||
player.events.start.add(() => {
|
||||
console.log('start run play ');
|
||||
});
|
||||
|
||||
player.events.objectSelected.add((obj: THREE.Object3D) => {
|
||||
if (obj) {
|
||||
// const selectObject = (obj as Object3DWrap).ancestors;
|
||||
onSelected(obj);
|
||||
}
|
||||
});
|
||||
loading.value = true;
|
||||
{
|
||||
const url = '/models/steelmill2/scene.glb';
|
||||
const loadmanager = THREE.DefaultLoadingManager;
|
||||
// loadmanager.onLoad=()
|
||||
const scene = await player.loader.loadFile(url, loadmanager);
|
||||
|
||||
scene.name = 'steelmill';
|
||||
progressiveSurfacemap = new ProgressiveLightMap(player.renderer!, lightMapRes);
|
||||
player.addObject(scene);
|
||||
}
|
||||
|
||||
// 加载hdr材质 并设置环境光贴图
|
||||
const rgbeLoader = new RGBELoader();
|
||||
const envMap = await rgbeLoader.loadAsync('/textures/skybox/industrial_sunset_02_puresky_4k.hdr ');
|
||||
envMap.mapping = THREE.EquirectangularReflectionMapping;
|
||||
// player.scene.background = envMap;
|
||||
player.scene.environment = envMap;
|
||||
|
||||
// 创建弹窗的css2d模型
|
||||
// popoverObject = new CSS2DObject(popoverRef.value!);
|
||||
|
||||
player.renderer!.toneMapping = THREE.NeutralToneMapping;
|
||||
player.renderer!.toneMappingExposure = 1;
|
||||
// 设置阴影
|
||||
player.enableShadows();
|
||||
|
||||
player.scene.traverse((item) => {
|
||||
// console.log('item-', item.name, item);
|
||||
if (item instanceof THREE.Mesh) {
|
||||
item.material.envMap = envMap;
|
||||
item.material.envMapIntensity = 0.8;
|
||||
// item.material.needsUpdate = true;
|
||||
lightmapObjects.push(item);
|
||||
progressiveSurfacemap.addObjectsToLightMap(lightmapObjects);
|
||||
} else {
|
||||
// item.layers.disableAll(); // Disable Rendering for this
|
||||
}
|
||||
});
|
||||
// player.scene.layers.set(1);
|
||||
player.scene.traverse((item) => {
|
||||
if (!(item instanceof THREE.Object3D)) {
|
||||
return;
|
||||
}
|
||||
if (canSelect(item)) {
|
||||
for (let i = 0; i < item.children.length; i++) {
|
||||
const group = item.children[i];
|
||||
//递归遍历chooseObj,并给chooseObj的所有子孙后代设置一个ancestors属性指向自己
|
||||
group.traverse(function (obj) {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
(obj as any).ancestors = item;
|
||||
}
|
||||
});
|
||||
}
|
||||
// intersectObjects.push(item);
|
||||
}
|
||||
});
|
||||
player.events.update.add((_timer) => {
|
||||
composer.render();
|
||||
// progressiveSurfacemap.update(player.camera!, 200, true);
|
||||
});
|
||||
{
|
||||
// 添加光照
|
||||
const directionalLight = new THREE.DirectionalLight(0xfdf5ed, 8);
|
||||
directionalLight.castShadow = true;
|
||||
// directionalLight.shadow.bias = -0.05;
|
||||
directionalLight.shadow.camera.left = -100;
|
||||
directionalLight.shadow.camera.right = 100;
|
||||
directionalLight.shadow.camera.top = 100;
|
||||
directionalLight.shadow.camera.bottom = -100;
|
||||
directionalLight.shadow.camera.near = 2;
|
||||
directionalLight.shadow.camera.far = 500;
|
||||
directionalLight.shadow.mapSize.set(shadowMapRes, shadowMapRes);
|
||||
directionalLight.shadow.radius = 2;
|
||||
player.scene.add(directionalLight);
|
||||
lightmapObjects.push(directionalLight);
|
||||
player.renderer!.shadowMap.type = THREE.VSMShadowMap;
|
||||
directionalLight.position.set(26, 48, 20);
|
||||
|
||||
// player.scene.add(new THREE.AmbientLight(0xfefefe, 1));
|
||||
player.scene.add(new THREE.HemisphereLight(0xffffff, 0x000000, 0.5));
|
||||
}
|
||||
|
||||
//
|
||||
//播放动画
|
||||
const model = player.scene.getObjectByName('steelmill')!;
|
||||
const animations = model.animations;
|
||||
for (const ani of animations) {
|
||||
player.addAnimation(animations, ani.name, model);
|
||||
}
|
||||
// player.controls?.addEventListener('change', (ev) => {
|
||||
// console.log('camera', ev.target.target, ev.target.object.position, player.camera!.position);
|
||||
// });
|
||||
player.controls!.maxDistance = 200;
|
||||
(player.camera! as THREE.PerspectiveCamera).near = 0.4;
|
||||
(player.camera! as THREE.PerspectiveCamera).far = 300;
|
||||
player.camera!.position.set(49.08655711956998, 15.013313129229896, 29.585290041126118);
|
||||
player.controls?.saveState();
|
||||
// const cameraHelper = new THREE.CameraHelper(player.camera!);
|
||||
// player.scene.add(cameraHelper);
|
||||
// if (process.env.NODE_ENV !== 'development') {
|
||||
// cameraHelper.visible = false;
|
||||
// }
|
||||
player.play();
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
function canSelect(object?: THREE.Object3D) {
|
||||
const intersectObjectsKey = equipmentInfos.value.map((v) => v.key);
|
||||
// equipmentInfos.value?.find((v) => v.key == object!.name || v.key == object?.userData.name);
|
||||
// if (object?.isObject3D) {
|
||||
// console.log('intersectObjectsKey', object?.userData.name);
|
||||
// }
|
||||
return object && intersectObjectsKey.includes(object?.userData.name);
|
||||
}
|
||||
let chooseObject: THREE.Object3D | null = null;
|
||||
function onSelected(object?: THREE.Object3D) {
|
||||
const selectObject = (object as any).ancestors || object;
|
||||
// console.log('selectObject', selectObject);
|
||||
|
||||
if (!selectObject || !canSelect(selectObject)) {
|
||||
outlinePass.selectedObjects = [];
|
||||
chooseObject?.remove(popoverObject);
|
||||
chooseObject = null;
|
||||
player.events.objectSelected.dispatch(null);
|
||||
return;
|
||||
}
|
||||
chooseObject = selectObject;
|
||||
let current = equipmentInfos.value?.find((v) => v.key == selectObject.userData.name);
|
||||
|
||||
// console.log('outlinedObjects', outlinedObjects);
|
||||
outlinePass.selectedObjects = [selectObject];
|
||||
if (current && current.key !== 'GaoLu') {
|
||||
selectedObjectInfo.value = {
|
||||
...current,
|
||||
};
|
||||
showPopover.value = true;
|
||||
}
|
||||
player.events.objectFocused.dispatch(selectObject);
|
||||
}
|
||||
const findObject = (key: string) => {
|
||||
let object = player.scene.getObjectByName(key);
|
||||
if (!object) {
|
||||
player.scene.traverse((child) => {
|
||||
if (child.userData.name === key) {
|
||||
object = child;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
return object;
|
||||
};
|
||||
const handleFocusTo = (focusKey: string) => {
|
||||
const object = findObject(focusKey);
|
||||
if (object) {
|
||||
onSelected(object);
|
||||
}
|
||||
};
|
||||
onUnmounted(() => {
|
||||
player?.dispose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
/* Blender风格右侧面板 */
|
||||
#viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #e0e0e0;
|
||||
background: #282828;
|
||||
border-left: 1px solid #333;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
height: 200px;
|
||||
margin: 10px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 详情区域 */
|
||||
.detail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
justify-content: flex-start;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: #e0e0e0;
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
}
|
||||
|
||||
/* 属性项 */
|
||||
.detail > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 属性标签 */
|
||||
.detail label {
|
||||
margin-right: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
/* 属性值 */
|
||||
.detail > div > div {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 值和单位 */
|
||||
.detail span {
|
||||
margin-left: 5px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 无选择状态 */
|
||||
.detail:empty::before {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
content: '请选择一个模型查看属性';
|
||||
}
|
||||
</style>
|
||||
101
apps/steelmill/src/pages/steelmill2/components/Pressure.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<BaseChart class="guage-chart" :option="option"></BaseChart>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import BaseChart from '@/components/charts/BaseChart.vue';
|
||||
const props = defineProps<{
|
||||
value: number;
|
||||
title?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}>();
|
||||
const option = computed(() => {
|
||||
const value = props.value;
|
||||
return {
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
},
|
||||
title: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
splitNumber: 10,
|
||||
radius: '70%',
|
||||
min: 0,
|
||||
max: 10,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [[1, '#f00']],
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
splitLine: {
|
||||
distance: -5,
|
||||
length: 4,
|
||||
lineStyle: {
|
||||
color: '#000',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
distance: -5,
|
||||
length: 2,
|
||||
lineStyle: {
|
||||
color: '#000',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
distance: -13,
|
||||
color: '#f00',
|
||||
fontSize: 10,
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
size: 5,
|
||||
itemStyle: {
|
||||
borderColor: '#000',
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
pointer: {
|
||||
offsetCenter: [0, '10%'],
|
||||
icon: 'path://M2090.36389,615.30999 L2090.36389,615.30999 C2091.48372,615.30999 2092.40383,616.194028 2092.44859,617.312956 L2096.90698,728.755929 C2097.05155,732.369577 2094.2393,735.416212 2090.62566,735.56078 C2090.53845,735.564269 2090.45117,735.566014 2090.36389,735.566014 L2090.36389,735.566014 C2086.74736,735.566014 2083.81557,732.63423 2083.81557,729.017692 C2083.81557,728.930412 2083.81732,728.84314 2083.82081,728.755929 L2088.2792,617.312956 C2088.32396,616.194028 2089.24407,615.30999 2090.36389,615.30999 Z',
|
||||
length: '115%',
|
||||
itemStyle: {
|
||||
color: '#000',
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
precision: 1,
|
||||
formatter: '{value} Pa',
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
},
|
||||
title: {
|
||||
offsetCenter: [0, '-50%'],
|
||||
fontSize: 12,
|
||||
color: '#fefefe',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: value,
|
||||
name: '压力',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.guage-chart {
|
||||
max-width: 200px;
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<template></template>
|
||||
122
apps/steelmill/src/pages/steelmill2/components/Status.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="status-container" :class="cls">
|
||||
<div class="status-warpper">
|
||||
<div class="header" v-if="slots.title || title">
|
||||
<div class="title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
const props = defineProps<{ title?: string; status?: 'error' | 'success'; frame?: boolean }>();
|
||||
const cls = computed(() => {
|
||||
return [`status-${props.status || 'success'}`, props.frame ? 'status-container-frame' : ''];
|
||||
});
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.status-container {
|
||||
--body-text-color: #28d68c;
|
||||
--border-frame-color: #28d68c;
|
||||
--background-color: rgba(7, 85, 140, 0.3);
|
||||
--boder-color: rgba(99, 145, 180, 0.3);
|
||||
--box-shadow-color: rgba(5, 157, 222, 0.25);
|
||||
--header-background-color: rgba(7, 85, 140, 0.5);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid var(--boder-color);
|
||||
box-shadow: inset 0 0 30px var(--box-shadow-color);
|
||||
.status-warpper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
background-color: rgb(7, 84, 140, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 8px;
|
||||
font-size: 0.16rem;
|
||||
line-height: 0.3rem;
|
||||
.title {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-image: linear-gradient(
|
||||
244deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
|
||||
rgb(42, 227, 243) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
// background-image: linear-gradient(to right, rgba(7, 85, 140, 0.3), yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
.body {
|
||||
// width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
font-size: 0.2rem;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
&.status-success {
|
||||
--body-text-color: #0ff37b;
|
||||
}
|
||||
&.status-error {
|
||||
--body-text-color: rgba(255, 5, 1, 1);
|
||||
--border-frame-color: rgba(255, 5, 5, 1);
|
||||
--background-color: rgba(255, 5, 1, 0.1);
|
||||
--boder-color: rgba(255, 5, 1, 0.3);
|
||||
--box-shadow-color: rgba(255, 5, 1, 0.3);
|
||||
--header-background-color: rgba(255, 5, 1, 0.2);
|
||||
}
|
||||
&.status-container-frame {
|
||||
background:
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px top -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) left -4px bottom -4px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -8px,
|
||||
linear-gradient(var(--border-frame-color), var(--border-frame-color)) right -4px bottom -4px;
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
4px 22px,
|
||||
22px 4px;
|
||||
// .header {
|
||||
// background-color: transparent;
|
||||
// }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
apps/steelmill/src/pages/steelmill2/components/Temperature.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<BaseChart class="guage-chart" :option="option"></BaseChart>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import BaseChart from '@/components/charts/BaseChart.vue';
|
||||
const props = defineProps<{
|
||||
value: number;
|
||||
title?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}>();
|
||||
const option = computed(() => {
|
||||
const value = props.value;
|
||||
return {
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
},
|
||||
title: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '温度',
|
||||
type: 'gauge',
|
||||
startAngle: 200,
|
||||
endAngle: -20,
|
||||
min: props.min || 0,
|
||||
max: props.max || 90,
|
||||
center: ['50%', '60%'],
|
||||
radius: '90%',
|
||||
itemStyle: {
|
||||
color: '#FFAB91',
|
||||
},
|
||||
pointer: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
fontSize: 8,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 4,
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
distance: 1,
|
||||
length: 3,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
progress: {
|
||||
show: true,
|
||||
width: 4,
|
||||
},
|
||||
detail: {
|
||||
offsetCenter: [0, '20%'],
|
||||
formatter: '{value} °C',
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
valueAnimation: true,
|
||||
},
|
||||
title: {
|
||||
offsetCenter: [0, '-20%'],
|
||||
fontSize: 12,
|
||||
color: '#fefefe',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value,
|
||||
name: '温度',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gauge',
|
||||
center: ['50%', '60%'],
|
||||
radius: '90%',
|
||||
startAngle: 200,
|
||||
endAngle: -20,
|
||||
min: props.min || 0,
|
||||
max: props.max || 90,
|
||||
itemStyle: {
|
||||
color: '#FD7347',
|
||||
},
|
||||
progress: {
|
||||
show: true,
|
||||
width: 1,
|
||||
},
|
||||
pointer: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
detail: {
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.guage-chart {
|
||||
min-height: 80px;
|
||||
}
|
||||
</style>
|
||||
28
apps/steelmill/src/pages/steelmill2/context.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { InjectionKey, Ref, inject } from 'vue';
|
||||
import signals from 'signals';
|
||||
export type FocusToCallback = (key: string) => void;
|
||||
|
||||
export interface SteelmillContext {
|
||||
// kongyaji: Ref<Array<Record<string, any>>>;
|
||||
// zhusuji: Ref<Array<Record<string, any>>>;
|
||||
// chongyaji: Ref<Array<Record<string, any>>>;
|
||||
status?: Ref<Array<Record<string, any>>>;
|
||||
events: {
|
||||
focusTo: signals.Signal<any>;
|
||||
warn: signals.Signal<any>;
|
||||
objectSelected: signals.Signal<any>;
|
||||
};
|
||||
// onFocusTo: (callback: FocusToCallback) => void;
|
||||
}
|
||||
export const injectContextKey = Symbol('$steelmillContext') as InjectionKey<SteelmillContext>;
|
||||
|
||||
export const useContext = () => {
|
||||
return inject(injectContextKey, {
|
||||
events: {
|
||||
focusTo: new signals.Signal(),
|
||||
warn: new signals.Signal(),
|
||||
objectSelected: new signals.Signal(),
|
||||
},
|
||||
// onFocusTo: () => {},
|
||||
});
|
||||
};
|
||||
24
apps/steelmill/src/router/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createWebHashHistory, createRouter } from 'vue-router';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
name: 'home',
|
||||
path: '/',
|
||||
meta: { title: '钢铁厂数字孪生平台' },
|
||||
component: () => import('@/pages/steelmill2/Index.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0, behavior: 'smooth' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default router;
|
||||
115
apps/steelmill/src/style.css
Normal file
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
font-family: 'Alibaba PuHuiTi 2.0', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: rgb(255 255 255 / 87%);
|
||||
color-scheme: light dark;
|
||||
background-color: #242424;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body,
|
||||
div,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
ul,
|
||||
ol,
|
||||
li,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
pre,
|
||||
code,
|
||||
form,
|
||||
fieldset,
|
||||
legend,
|
||||
input,
|
||||
textarea,
|
||||
p,
|
||||
blockquote,
|
||||
th,
|
||||
td,
|
||||
hr,
|
||||
button,
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
section {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.6em 1.2em;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
291
apps/steelmill/src/three/controls.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { OrbitControls } from 'three/examples/jsm/Addons.js';
|
||||
import TWEEN, { Easing } from '@tweenjs/tween.js';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const focusEvent = { type: 'focus' };
|
||||
const changeEvent = { type: 'change' };
|
||||
|
||||
type ControlOptions = {
|
||||
autoRotate: boolean;
|
||||
autoRotateSpeed: number;
|
||||
minDistance: number;
|
||||
maxDistance: number;
|
||||
dampingFactor: number;
|
||||
enableDamping: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
class PlayerControls extends THREE.EventDispatcher<any> {
|
||||
private object: THREE.Camera;
|
||||
private domElement: HTMLElement;
|
||||
private orbitControls: OrbitControls;
|
||||
enabled: boolean = true;
|
||||
constructor(object: THREE.Camera, domElement: HTMLElement, options?: ControlOptions) {
|
||||
super();
|
||||
this.object = object;
|
||||
this.domElement = domElement;
|
||||
this.orbitControls = new OrbitControls(this.object, this.domElement);
|
||||
this.orbitControls.minDistance = options?.minDistance || 5;
|
||||
this.orbitControls.maxDistance = options?.maxDistance || 100;
|
||||
this.orbitControls.enableDamping = options?.enableDamping || false; //启用阻尼
|
||||
this.orbitControls.enabled = options?.enabled ?? true;
|
||||
this.orbitControls.maxPolarAngle = THREE.MathUtils.degToRad(90);
|
||||
this.addDomEvents();
|
||||
|
||||
this.orbitControls.addEventListener('change', () => {
|
||||
// 浏览器控制台查看相机位置变化
|
||||
this.dispatchEvent(changeEvent);
|
||||
});
|
||||
}
|
||||
private addDomEvents() {
|
||||
this.domElement.addEventListener('click', this.onClick);
|
||||
this.domElement.addEventListener('pointermove', this.onPointerMove);
|
||||
this.domElement.addEventListener('dblclick', this.onDoubleClick.bind(this));
|
||||
}
|
||||
|
||||
private onClick(_event: MouseEvent) {}
|
||||
private onDoubleClick() {
|
||||
this.reset();
|
||||
}
|
||||
private onPointerMove() {}
|
||||
|
||||
public reset(animate: boolean = true) {
|
||||
if (!animate) {
|
||||
this.orbitControls.reset();
|
||||
return;
|
||||
}
|
||||
const camera = this.object;
|
||||
const controls = this.orbitControls;
|
||||
|
||||
const target = controls.target0.clone();
|
||||
const position = controls.position0.clone();
|
||||
|
||||
new TWEEN.Tween({
|
||||
// target: controls.target,
|
||||
tx: controls.target.x,
|
||||
ty: controls.target.y,
|
||||
tz: controls.target.z,
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
// position: controls.object.position,
|
||||
// zoom: controls.object.,
|
||||
})
|
||||
.to(
|
||||
{
|
||||
tx: target.x,
|
||||
ty: target.y,
|
||||
tz: target.z,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
z: position.z,
|
||||
// target: target,
|
||||
// position: position,
|
||||
// zoom: zoom,
|
||||
},
|
||||
1000
|
||||
)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(function (obj) {
|
||||
// 动态改变相机位置
|
||||
camera.position.set(obj.x, obj.y, obj.z);
|
||||
// 动态计算相机视线
|
||||
// camera.lookAt(obj.tx, obj.ty, obj.tz);
|
||||
controls.target.set(obj.tx, obj.ty, obj.tz);
|
||||
controls.update(); //内部会执行.lookAt()
|
||||
})
|
||||
.start();
|
||||
}
|
||||
public saveState() {
|
||||
this.orbitControls.saveState();
|
||||
}
|
||||
public update(deltaTime?: number): boolean {
|
||||
return this.orbitControls.update(deltaTime);
|
||||
}
|
||||
/**
|
||||
* Returns the distance from the camera to the target.
|
||||
* @returns number
|
||||
*/
|
||||
public getDistance() {
|
||||
return this.orbitControls.getDistance();
|
||||
}
|
||||
get maxDistance() {
|
||||
return this.orbitControls.maxDistance;
|
||||
}
|
||||
set maxDistance(value: number) {
|
||||
this.orbitControls.maxDistance = value;
|
||||
}
|
||||
set minDistance(value: number) {
|
||||
this.orbitControls.minDistance = value;
|
||||
}
|
||||
set dampingFactor(value: number) {
|
||||
this.orbitControls.dampingFactor = value;
|
||||
}
|
||||
set enableDamping(value: boolean) {
|
||||
this.orbitControls.enableDamping = value;
|
||||
}
|
||||
set autoRotate(value: boolean) {
|
||||
this.orbitControls.autoRotate = value;
|
||||
}
|
||||
set autoRotateSpeed(value: number) {
|
||||
this.orbitControls.autoRotateSpeed = value;
|
||||
}
|
||||
set maxPolarAngle(value: number) {
|
||||
this.orbitControls.maxPolarAngle = value;
|
||||
}
|
||||
set minPolarAngle(value: number) {
|
||||
this.orbitControls.minPolarAngle = value;
|
||||
}
|
||||
set minAzimuthAngle(value: number) {
|
||||
this.orbitControls.minAzimuthAngle = value;
|
||||
}
|
||||
set maxAzimuthAngle(value: number) {
|
||||
this.orbitControls.maxAzimuthAngle = value;
|
||||
}
|
||||
set enablePan(value: boolean) {
|
||||
this.orbitControls.enablePan = value;
|
||||
}
|
||||
/**
|
||||
* 将相机聚焦到指定的物体,并拉近相机距离
|
||||
* @param target Object3D - 聚焦的目标
|
||||
* @param options.scalar - number default 5 , 相机位置的矩阵乘该标量值用于与聚焦对象拉开一定距离
|
||||
*/
|
||||
public focus(
|
||||
target: THREE.Object3D,
|
||||
options?: {
|
||||
scalar?: number;
|
||||
angle?: number;
|
||||
}
|
||||
) {
|
||||
const _DEFAULT_OPTIONS = {
|
||||
scalar: 5,
|
||||
};
|
||||
const camera = this.object;
|
||||
const controls = this.orbitControls;
|
||||
|
||||
/** 相机位置与focus目标拉的偏移量 */
|
||||
const scalar = options?.scalar !== undefined ? options.scalar : _DEFAULT_OPTIONS.scalar;
|
||||
const angle = options?.angle !== undefined ? options.angle : -Math.PI / 4;
|
||||
|
||||
const box = new THREE.Box3();
|
||||
const center = new THREE.Vector3();
|
||||
const sphere = new THREE.Sphere();
|
||||
// 相机移动的偏移量
|
||||
const delta = new THREE.Vector3();
|
||||
let distance = 0;
|
||||
// 获取目标对象的包围盒
|
||||
box.setFromObject(target);
|
||||
box.getCenter(center);
|
||||
distance = box.getBoundingSphere(sphere).radius;
|
||||
|
||||
const quaternion = new THREE.Quaternion();
|
||||
target.getWorldQuaternion(quaternion);
|
||||
quaternion.copy(camera.quaternion);
|
||||
|
||||
quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1), angle));
|
||||
delta.set(0, 0, 1);
|
||||
delta.applyQuaternion(quaternion);
|
||||
delta.multiplyScalar(distance * scalar);
|
||||
|
||||
const cameraPositionStart = camera.position.clone();
|
||||
const cameraPositionEnd = center.clone().add(delta);
|
||||
// 相机初始观察点
|
||||
const targetStartPoint: THREE.Vector3 = controls.target.clone();
|
||||
const targetEndPoint = center.clone();
|
||||
const eventdispatcher = this.dispatchEvent.bind(this);
|
||||
new TWEEN.Tween({
|
||||
// 不管相机此刻处于什么状态,直接读取当前的位置和目标观察点
|
||||
x: cameraPositionStart.x,
|
||||
y: cameraPositionStart.y,
|
||||
z: cameraPositionStart.z,
|
||||
tx: targetStartPoint.x,
|
||||
ty: targetStartPoint.y,
|
||||
tz: targetStartPoint.z,
|
||||
})
|
||||
.to(
|
||||
{
|
||||
// 动画结束相机位置坐标
|
||||
x: cameraPositionEnd.x,
|
||||
y: cameraPositionEnd.y,
|
||||
z: cameraPositionEnd.z,
|
||||
tx: targetEndPoint.x,
|
||||
ty: targetEndPoint.y,
|
||||
tz: targetEndPoint.z,
|
||||
},
|
||||
1000
|
||||
)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(function (obj) {
|
||||
// 动态改变相机位置
|
||||
camera.position.set(obj.x, obj.y, obj.z);
|
||||
// 设置相机的视线
|
||||
controls.target.set(obj.tx, obj.ty, obj.tz);
|
||||
controls.update();
|
||||
})
|
||||
.onComplete(() => {
|
||||
eventdispatcher(focusEvent);
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
focus2(
|
||||
target: THREE.Object3D,
|
||||
options?: {
|
||||
scalar?: number;
|
||||
}
|
||||
) {
|
||||
const _DEFAULT_OPTIONS = {
|
||||
scalar: 14,
|
||||
};
|
||||
const scalar = options?.scalar !== undefined ? options.scalar : _DEFAULT_OPTIONS.scalar;
|
||||
const camera = this.object;
|
||||
const controls = this.orbitControls;
|
||||
const pos = new THREE.Vector3();
|
||||
target.getWorldPosition(pos);
|
||||
// 相机飞行到的位置和观察目标拉开一定的距离
|
||||
const endPos = pos.clone().addScalar(scalar);
|
||||
|
||||
// const quaternion = new THREE.Quaternion();
|
||||
// target.getWorldQuaternion(quaternion);
|
||||
// quaternion.copy(camera.quaternion);
|
||||
// // //相机以45度角俯视
|
||||
// quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1), Math.PI / 4));
|
||||
// endPos.applyQuaternion(quaternion);
|
||||
const endTarget = pos;
|
||||
|
||||
const cameraStartPos = camera.position;
|
||||
const controlsPos = controls.target;
|
||||
new TWEEN.Tween({
|
||||
// 不管相机此刻处于什么状态,直接读取当前的位置和目标观察点
|
||||
x: cameraStartPos.x,
|
||||
y: cameraStartPos.y,
|
||||
z: cameraStartPos.z,
|
||||
tx: controlsPos.x,
|
||||
ty: controlsPos.y,
|
||||
tz: controlsPos.z,
|
||||
})
|
||||
.to(
|
||||
{
|
||||
// 动画结束相机位置坐标
|
||||
x: endPos.x,
|
||||
y: endPos.y,
|
||||
z: endPos.z,
|
||||
// 动画结束相机指向的目标观察点
|
||||
tx: endTarget.x,
|
||||
ty: endTarget.y,
|
||||
tz: endTarget.z,
|
||||
},
|
||||
1000
|
||||
)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(function (obj) {
|
||||
// 动态改变相机位置
|
||||
camera.position.set(obj.x, obj.y, obj.z);
|
||||
// 动态计算相机视线
|
||||
// camera.lookAt(obj.tx, obj.ty, obj.tz);
|
||||
controls.target.set(obj.tx, obj.ty, obj.tz);
|
||||
controls.update(); //内部会执行.lookAt()
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
export default PlayerControls;
|
||||
1
apps/steelmill/src/three/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './player';
|
||||
66
apps/steelmill/src/three/loader.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { LoadingManager } from 'three';
|
||||
|
||||
// function formatNumber(num: number) {
|
||||
// return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
|
||||
// }
|
||||
class Loader {
|
||||
dracoPath: string;
|
||||
ktx2Path: string;
|
||||
constructor(options: { dracoPath: string; ktx2Path: string } = { dracoPath: '/draco/', ktx2Path: '/basis/' }) {
|
||||
this.dracoPath = options?.dracoPath;
|
||||
this.ktx2Path = options.ktx2Path;
|
||||
}
|
||||
|
||||
async loadFile(url: string, manager?: LoadingManager) {
|
||||
const extension = url!.split('.')?.pop()?.toLowerCase();
|
||||
// const reader = new FileReader();
|
||||
// reader.addEventListener('progress', function (event) {
|
||||
// const size = '(' + formatNumber(Math.floor(event.total / 1000)) + ' KB)';
|
||||
// const progress = Math.floor((event.loaded / event.total) * 100) + '%';
|
||||
|
||||
// console.log('Loading', file, size, progress);
|
||||
// });
|
||||
|
||||
switch (extension) {
|
||||
case 'glb':
|
||||
case 'gltf': {
|
||||
const loader = await this.createGLTFLoader(manager);
|
||||
const result = await loader.loadAsync(url);
|
||||
const scene = result.scene;
|
||||
scene.name = url;
|
||||
scene.animations.push(...result.animations);
|
||||
|
||||
loader?.dracoLoader?.dispose();
|
||||
loader?.ktx2Loader?.dispose();
|
||||
return scene;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('not supported yet');
|
||||
}
|
||||
}
|
||||
|
||||
private async createGLTFLoader(manager?: LoadingManager) {
|
||||
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
|
||||
const { DRACOLoader } = await import('three/addons/loaders/DRACOLoader.js');
|
||||
const { KTX2Loader } = await import('three/addons/loaders/KTX2Loader.js');
|
||||
const { MeshoptDecoder } = await import('three/addons/libs/meshopt_decoder.module.js');
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(this.dracoPath);
|
||||
|
||||
const ktx2Loader = new KTX2Loader();
|
||||
ktx2Loader.setTranscoderPath(this.ktx2Path);
|
||||
|
||||
// editor.signals.rendererDetectKTX2Support.dispatch(ktx2Loader);
|
||||
|
||||
const loader = new GLTFLoader(manager);
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
loader.setKTX2Loader(ktx2Loader);
|
||||
loader.setMeshoptDecoder(MeshoptDecoder);
|
||||
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
|
||||
export default Loader;
|
||||
341
apps/steelmill/src/three/player.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import * as THREE from 'three';
|
||||
import signals, { Signal } from 'signals';
|
||||
import Loader from './loader';
|
||||
import { CSS2DRenderer, CSS3DRenderer } from 'three/examples/jsm/Addons.js';
|
||||
import PlayerControls from './controls';
|
||||
import Selector from './selector';
|
||||
import TWEEN from '@tweenjs/tween.js';
|
||||
|
||||
// import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
|
||||
|
||||
const _DEFAULT_CAMERA = new THREE.PerspectiveCamera(50, 1, 0.01, 1000);
|
||||
_DEFAULT_CAMERA.name = 'Camera';
|
||||
_DEFAULT_CAMERA.position.set(0, 5, 10);
|
||||
_DEFAULT_CAMERA.lookAt(new THREE.Vector3());
|
||||
|
||||
let time: number, startTime: number; // prevTime: number;
|
||||
const clock = new THREE.Clock();
|
||||
// type EventListenerHandler = (...params: any[]) => void;
|
||||
type Events = {
|
||||
init: Signal<any>;
|
||||
start: Signal<any>;
|
||||
stop: Signal<any>;
|
||||
keydown: Signal<any>;
|
||||
keyup: Signal<any>;
|
||||
pointerdown: Signal<any>;
|
||||
pointerup: Signal<any>;
|
||||
pointermove: Signal<any>;
|
||||
update: Signal<any>;
|
||||
resize: Signal<any>;
|
||||
intersectionsDetected: Signal<any>;
|
||||
objectFocused: Signal<any>;
|
||||
objectSelected: Signal<any>;
|
||||
};
|
||||
export class Player {
|
||||
width: any;
|
||||
height: any;
|
||||
dom: HTMLElement;
|
||||
renderer?: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera?: THREE.Camera;
|
||||
loader: Loader;
|
||||
private resizeObserver: ResizeObserver;
|
||||
selector: Selector;
|
||||
controls?: PlayerControls;
|
||||
private mixers: THREE.AnimationMixer[] = [];
|
||||
|
||||
constructor() {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.id = 'player';
|
||||
|
||||
this.loader = new Loader();
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.localClippingEnabled = true;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.clearDepth();
|
||||
|
||||
this.dom.appendChild(this.renderer.domElement);
|
||||
//set camera
|
||||
this.setCamera(_DEFAULT_CAMERA.clone());
|
||||
// init scene
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.name = 'Scene';
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
// console.log('resizeObserver===>', width, height);
|
||||
this.onResize(width, height);
|
||||
});
|
||||
this.resizeObserver.observe(this.dom);
|
||||
this.selector = new Selector(this);
|
||||
this.events.objectFocused.add((object) => {
|
||||
this.controls?.focus2(object);
|
||||
});
|
||||
}
|
||||
|
||||
private onResize(width: number, height: number) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
this.setSize(width, height);
|
||||
this.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
this.events.resize.dispatch(width, height);
|
||||
}
|
||||
private _events: Events = {
|
||||
init: new signals.Signal(),
|
||||
start: new signals.Signal(),
|
||||
stop: new signals.Signal(),
|
||||
keydown: new signals.Signal(),
|
||||
keyup: new signals.Signal(),
|
||||
pointerdown: new signals.Signal(),
|
||||
pointerup: new signals.Signal(),
|
||||
pointermove: new signals.Signal(),
|
||||
update: new signals.Signal(),
|
||||
resize: new signals.Signal(),
|
||||
intersectionsDetected: new signals.Signal(),
|
||||
objectFocused: new signals.Signal(),
|
||||
objectSelected: new signals.Signal(),
|
||||
};
|
||||
get events() {
|
||||
return this._events;
|
||||
}
|
||||
get cavans() {
|
||||
return this.renderer!.domElement;
|
||||
}
|
||||
public enableShadows() {
|
||||
this.renderer!.shadowMap.enabled = true;
|
||||
this.renderer!.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
public addAnimation(animations: Array<THREE.AnimationClip>, animationName: string, target: THREE.Object3D) {
|
||||
const mixer = new THREE.AnimationMixer(target);
|
||||
const clip = THREE.AnimationClip.findByName(animations, animationName);
|
||||
if (!clip) return undefined;
|
||||
const action = mixer.clipAction(clip);
|
||||
action.play();
|
||||
this.mixers.push(mixer);
|
||||
}
|
||||
public addAnimationMixer(mixer: THREE.AnimationMixer) {
|
||||
this.mixers.push(mixer);
|
||||
}
|
||||
public addCSS2DRenderer() {
|
||||
const CSSRenderer = new CSS2DRenderer();
|
||||
CSSRenderer.setSize(this.width, this.height);
|
||||
CSSRenderer.domElement.style.position = 'absolute';
|
||||
CSSRenderer.domElement.style.top = '0px';
|
||||
CSSRenderer.domElement.style.pointerEvents = 'none';
|
||||
this.dom.appendChild(CSSRenderer.domElement);
|
||||
|
||||
this.events.update.add(() => {
|
||||
CSSRenderer.render(this.scene, this.camera!);
|
||||
});
|
||||
this.events.resize.add((width, height) => {
|
||||
CSSRenderer.setSize(width, height);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addCSS3Renderer() {
|
||||
const css3Renderer = new CSS3DRenderer();
|
||||
css3Renderer.setSize(this.width, this.height);
|
||||
// HTML标签<div id="tag"></div>外面父元素叠加到canvas画布上且重合
|
||||
css3Renderer.domElement.style.position = 'absolute';
|
||||
css3Renderer.domElement.style.top = '0px';
|
||||
//设置.pointerEvents=none,解决HTML元素标签对threejs canvas画布鼠标事件的遮挡
|
||||
css3Renderer.domElement.style.pointerEvents = 'none';
|
||||
this.dom.appendChild(css3Renderer.domElement);
|
||||
this.events.update.add(() => {
|
||||
css3Renderer.render(this.scene, this.camera!);
|
||||
});
|
||||
this.events.resize.add((width, height) => {
|
||||
css3Renderer.setSize(width, height);
|
||||
});
|
||||
}
|
||||
public setCamera(value: THREE.Camera) {
|
||||
this.camera = value;
|
||||
if (this.camera instanceof THREE.PerspectiveCamera) {
|
||||
this.camera.aspect = this.width / this.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
public addControls() {
|
||||
this.controls = new PlayerControls(this.camera!, this.cavans);
|
||||
this.events.update.add(() => {
|
||||
this.controls?.update();
|
||||
});
|
||||
}
|
||||
public setScene(scene: THREE.Scene) {
|
||||
// this.scene = value;
|
||||
this.scene.background = scene.background;
|
||||
this.scene.environment = scene.environment;
|
||||
this.scene.fog = scene.fog;
|
||||
this.scene.backgroundBlurriness = scene.backgroundBlurriness;
|
||||
this.scene.backgroundIntensity = scene.backgroundIntensity;
|
||||
|
||||
this.scene.userData = JSON.parse(JSON.stringify(scene.userData));
|
||||
while (scene.children.length > 0) {
|
||||
this.addObject(scene.children[0]);
|
||||
}
|
||||
}
|
||||
public setPixelRatio(pixelRatio: number) {
|
||||
this.renderer?.setPixelRatio(pixelRatio);
|
||||
}
|
||||
public setSize(width: number, height: number) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
if (this.camera && this.camera instanceof THREE.PerspectiveCamera) {
|
||||
this.camera.aspect = this.width / this.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
this.renderer?.setSize(width, height);
|
||||
}
|
||||
|
||||
public addLight(light?: THREE.Light) {
|
||||
if (light) {
|
||||
this.addObject(light);
|
||||
return this;
|
||||
}
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 5);
|
||||
directionalLight.position.set(3, 15, 18);
|
||||
directionalLight.castShadow = true;
|
||||
directionalLight.shadow.camera.left = -100;
|
||||
directionalLight.shadow.camera.right = 100;
|
||||
directionalLight.shadow.camera.top = 100;
|
||||
directionalLight.shadow.camera.bottom = -100;
|
||||
directionalLight.shadow.camera.near = 1;
|
||||
directionalLight.shadow.camera.far = 500;
|
||||
directionalLight.shadow.mapSize.set(512, 512);
|
||||
directionalLight.shadow.radius = 1;
|
||||
const directionalLight1 = directionalLight.clone();
|
||||
directionalLight1.position.set(-3, 15, -18);
|
||||
this.addObject(directionalLight);
|
||||
this.addObject(directionalLight1);
|
||||
const ambientLight = new THREE.AmbientLight(0xfefefe, 3);
|
||||
// scene.value!.add(ambientLight);
|
||||
this.addObject(ambientLight);
|
||||
|
||||
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 2);
|
||||
hemisphereLight.position.set(0, 8, 0);
|
||||
this.addObject(hemisphereLight);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addGridHelpers() {
|
||||
// helpers
|
||||
const GRID_COLORS_LIGHT = [0x999999, 0x777777];
|
||||
// const GRID_COLORS_DARK = [0x555555, 0x888888];
|
||||
|
||||
const grid = new THREE.Group();
|
||||
|
||||
const grid1 = new THREE.GridHelper(30, 30);
|
||||
grid1.material.color.setHex(GRID_COLORS_LIGHT[0]);
|
||||
grid1.material.vertexColors = false;
|
||||
grid.add(grid1);
|
||||
|
||||
const grid2 = new THREE.GridHelper(30, 6);
|
||||
grid2.material.color.setHex(GRID_COLORS_LIGHT[1]);
|
||||
grid2.material.vertexColors = false;
|
||||
grid.add(grid2);
|
||||
this.events.update.add(() => {
|
||||
this.renderer?.render(grid, this.camera!);
|
||||
});
|
||||
}
|
||||
public addObject(object: THREE.Object3D, parent?: THREE.Object3D, index?: number) {
|
||||
if (parent === undefined) {
|
||||
this.scene?.add(object);
|
||||
} else {
|
||||
parent.children.splice(index || 0, 0, object);
|
||||
object.parent = parent;
|
||||
}
|
||||
}
|
||||
public play() {
|
||||
const animate = () => {
|
||||
time = performance.now();
|
||||
this.renderer?.render(this.scene!, this.camera!);
|
||||
const delta = clock.getDelta();
|
||||
|
||||
try {
|
||||
this.events.update.dispatch({ time: time - startTime, delta: delta });
|
||||
for (const mixer of this.mixers) {
|
||||
mixer.update(delta);
|
||||
}
|
||||
TWEEN.update();
|
||||
} catch (e: any) {
|
||||
console.error(e.message || e, e.stack || '');
|
||||
}
|
||||
|
||||
// prevTime = time;
|
||||
};
|
||||
|
||||
startTime = performance.now();
|
||||
|
||||
this.dom.addEventListener('click', this.onClick.bind(this));
|
||||
|
||||
this.events.start.dispatch();
|
||||
|
||||
this.renderer!.setAnimationLoop(animate);
|
||||
}
|
||||
public render(time?: number) {
|
||||
performance.now();
|
||||
this.events.update.dispatch({ time: time || 0 * 1000, delta: 0 /* TODO */ });
|
||||
this.renderer!.render(this.scene!, this.camera!);
|
||||
}
|
||||
public stop() {
|
||||
this.dom.removeEventListener('click', this.onClick.bind(this));
|
||||
|
||||
this.events.stop.dispatch();
|
||||
|
||||
this.renderer?.setAnimationLoop(null);
|
||||
}
|
||||
public dispose() {
|
||||
this.stop();
|
||||
this.renderer?.dispose();
|
||||
// dispose all objects
|
||||
this.scene?.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
this.scene.clear();
|
||||
this.cavans.remove();
|
||||
this.dom.remove();
|
||||
this.resizeObserver.disconnect();
|
||||
this.renderer = undefined;
|
||||
this.camera = undefined;
|
||||
}
|
||||
private getMousePosition(dom: HTMLElement, x: number, y: number) {
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return [(x - rect.left) / rect.width, (y - rect.top) / rect.height];
|
||||
}
|
||||
|
||||
// private onDoubleClickPosition = new THREE.Vector2();
|
||||
// private onDoubleClick(event: MouseEvent) {
|
||||
// const array = this.getMousePosition(this.dom, event.clientX, event.clientY);
|
||||
// this.onDoubleClickPosition = new THREE.Vector2().fromArray(array);
|
||||
// const intersects = this.selector.getPointerIntersects(this.onDoubleClickPosition, this.camera!);
|
||||
|
||||
// if (intersects.length > 0) {
|
||||
// const intersect = intersects[0];
|
||||
// this.events.objectFocused.dispatch(intersect.object);
|
||||
|
||||
// }
|
||||
// }
|
||||
private onClick(event: MouseEvent) {
|
||||
const array = this.getMousePosition(this.dom, event.clientX, event.clientY);
|
||||
const clickPosition = new THREE.Vector2().fromArray(array);
|
||||
const intersects = this.selector.getPointerIntersects(clickPosition, this.camera!);
|
||||
this.events.intersectionsDetected.dispatch(intersects);
|
||||
}
|
||||
}
|
||||
70
apps/steelmill/src/three/selector.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as THREE from 'three';
|
||||
import { Player } from './player';
|
||||
|
||||
const mouse = new THREE.Vector2();
|
||||
const raycaster = new THREE.Raycaster();
|
||||
class Selector {
|
||||
scene: THREE.Scene;
|
||||
selected: THREE.Object3D<THREE.Object3DEventMap> | null;
|
||||
player: Player;
|
||||
constructor(player: Player) {
|
||||
this.scene = player.scene;
|
||||
this.selected = null;
|
||||
this.player = player;
|
||||
// signals
|
||||
player.events.intersectionsDetected.add((intersects) => {
|
||||
if (intersects.length > 0) {
|
||||
const object = intersects[0].object;
|
||||
this.select(object);
|
||||
// if (object.userData.object !== undefined) {
|
||||
// // helper
|
||||
|
||||
// this.select(object.userData.object);
|
||||
// } else {
|
||||
// this.select(object);
|
||||
// }
|
||||
} else {
|
||||
this.select(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getIntersects(raycaster: THREE.Raycaster) {
|
||||
const objects: THREE.Object3D[] = [];
|
||||
|
||||
this.scene.traverseVisible(function (child) {
|
||||
objects.push(child);
|
||||
});
|
||||
|
||||
return raycaster.intersectObjects(objects, false);
|
||||
}
|
||||
|
||||
getPointerIntersects(point: THREE.Vector2, camera: THREE.Camera) {
|
||||
mouse.set(point.x * 2 - 1, -(point.y * 2) + 1);
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
|
||||
return this.getIntersects(raycaster);
|
||||
}
|
||||
|
||||
select(object: THREE.Object3D | null) {
|
||||
if (this.selected === object) return;
|
||||
|
||||
// const uuid = null;
|
||||
|
||||
// if (object !== null) {
|
||||
// uuid = object.uuid;
|
||||
// }
|
||||
|
||||
this.selected = object;
|
||||
// this.editor.config.setKey('selected', uuid);
|
||||
this.player.events.objectSelected.dispatch(object);
|
||||
// this.signals.objectSelected.dispatch(object);
|
||||
}
|
||||
|
||||
deselect() {
|
||||
this.select(null);
|
||||
}
|
||||
}
|
||||
|
||||
export default Selector;
|
||||
13
apps/steelmill/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'vue-router';
|
||||
|
||||
// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明
|
||||
export {};
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* 页面标题
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
}
|
||||
1
apps/steelmill/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||