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