feat: 新增钢铁厂数字孪生平台基础架构和功能模块

- 添加Three.js基础场景配置和核心功能模块
- 实现模型加载器、动画循环和交互选择器
- 添加温度、压力等仪表盘组件
- 配置Vite构建工具和ESLint规范
- 添加基础UI组件和布局系统
- 实现数据可视化图表组件
- 配置Nginx部署文件
- 添加钢铁厂设备数据模型
This commit is contained in:
砂糖
2025-11-28 16:58:15 +08:00
parent bffd7a0666
commit 74a066dd80
115 changed files with 20907 additions and 0 deletions

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

View 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

View 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

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,10 @@
import { Mesh, Object3D } from 'three';
class Object3DWrap extends Mesh {
public ancestors?: Object3D = undefined;
constructor() {
super();
}
}
export { Object3DWrap };

View 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);
}

View 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';

View 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;

View 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
);
};

View 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();
};

View 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);

View 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>

View 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>

View 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>

View 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

View 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;

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
<template></template>

View 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>

View 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>

View 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: () => {},
});
};

View 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;

View 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;
}
}

View 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;

View File

@@ -0,0 +1 @@
export * from './player';

View 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;

View 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);
}
}

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />