|
@@ -0,0 +1,329 @@
|
|
|
|
|
+import * as THREE from 'three';
|
|
|
|
|
+import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
|
|
|
|
|
+import { setModalCenter } from '/@/utils/threejs/util';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 注氮机地下管路模型(nitrogenFlex)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 模型构成:
|
|
|
|
|
+ * - 基底:nitrogen_base(管路 / 底座)
|
|
|
|
|
+ * - 注氮机:nitrogen_ele_1 / nitrogen_ele_2 / nitrogen_ele_3
|
|
|
|
|
+ *
|
|
|
|
|
+ * ── 重要:三个注氮机模型是「预制好机位」的,必须原位(identity)摆放 ──
|
|
|
|
|
+ */
|
|
|
|
|
+class NitrogenFlex {
|
|
|
|
|
+ model;
|
|
|
|
|
+ /** 基底模型名 */
|
|
|
|
|
+ baseName = 'nitrogen_base';
|
|
|
|
|
+ /** 注氮机模型名,按机数取前 N 个(对应 右 / 中 / 左 机位) */
|
|
|
|
|
+ unitNames = ['nitrogen_ele_1', 'nitrogen_ele_2', 'nitrogen_ele_3'];
|
|
|
|
|
+ /** 总根节点 */
|
|
|
|
|
+ group: THREE.Object3D = new THREE.Object3D();
|
|
|
|
|
+ /** 已加载的注氮机模板 */
|
|
|
|
|
+ private unitTemplates: (THREE.Object3D | null)[] = [];
|
|
|
|
|
+ /** 当前场景中实际显示的注氮机节点 */
|
|
|
|
|
+ private unitGroups: THREE.Object3D[] = [];
|
|
|
|
|
+ /** 本模型显示的 css 详情元素数组 */
|
|
|
|
|
+ cssSprites: CSS3DSprite[] = [];
|
|
|
|
|
+ /** 当前机数 */
|
|
|
|
|
+ nitrogenNum = 0;
|
|
|
|
|
+ /** css 精灵世界缩放(模型为原位摆放,无需再补偿父级缩放) */
|
|
|
|
|
+ private spriteScale = 0.008;
|
|
|
|
|
+ /** 模型是否加载完成 */
|
|
|
|
|
+ private loaded = false;
|
|
|
|
|
+
|
|
|
|
|
+ /** 所有机体整体的垂直微调(默认 0 = 模型设计高度) */
|
|
|
|
|
+ private unitLiftY = 0;
|
|
|
|
|
+ /** 所有机体整体的深度微调(默认 0) */
|
|
|
|
|
+ private unitOffsetZ = 0;
|
|
|
|
|
+ /** 下风侧监测挂载位置:相对基底包围盒归一化(0=最小边,1=最大边) */
|
|
|
|
|
+ private downWindAnchor = { nx: 0.04, ny: 0.75, nz: 0.5 };
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 相机取景:模型宽度占画面的比例(0.92 = 横向占满 92%)。
|
|
|
|
|
+ * 调大 = 相机更近 = 模型更大。
|
|
|
|
|
+ */
|
|
|
|
|
+ private cameraWidthFill = 0.92;
|
|
|
|
|
+ /** 相机距离下限(包围球拟合 × 该系数),防止超宽屏时贴太近被裁 */
|
|
|
|
|
+ private cameraMinFactor = 0.62;
|
|
|
|
|
+ /** 相机观察方向(从前上方俯视),会归一化后乘以上面的距离 */
|
|
|
|
|
+ private cameraDir = { x: 0.18, y: 0.85, z: 1.35 };
|
|
|
|
|
+ /** 机顶气泡再往上的高度 */
|
|
|
|
|
+ private spriteTopOffset = 0.34;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(model) {
|
|
|
|
|
+ this.model = model;
|
|
|
|
|
+ this.group.name = 'nitrogenFlex';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addLight() {
|
|
|
|
|
+ // 灯光按场景尺度推导,避免小场景过曝、大场景欠曝
|
|
|
|
|
+ const box = new THREE.Box3().setFromObject(this.group);
|
|
|
|
|
+ const size = box.getSize(new THREE.Vector3());
|
|
|
|
|
+ const center = box.getCenter(new THREE.Vector3());
|
|
|
|
|
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
|
|
|
|
+
|
|
|
|
|
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
|
|
|
+ directionalLight.position.set(center.x + maxDim * 0.6, center.y + maxDim * 1.2, center.z + maxDim * 0.8);
|
|
|
|
|
+ directionalLight.target = this.group;
|
|
|
|
|
+ this.group.add(directionalLight);
|
|
|
|
|
+
|
|
|
|
|
+ const pointLight = new THREE.PointLight(0xffffff, 1, maxDim * 3);
|
|
|
|
|
+ pointLight.position.set(center.x, center.y + maxDim * 0.9, center.z + maxDim * 0.5);
|
|
|
|
|
+ this.group.add(pointLight);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 加载基底与三台注氮机模型 */
|
|
|
|
|
+ mountedThree() {
|
|
|
|
|
+ return new Promise((resolve) => {
|
|
|
|
|
+ const names = [this.baseName, ...this.unitNames];
|
|
|
|
|
+ this.model
|
|
|
|
|
+ .setGLTFModel(names)
|
|
|
|
|
+ .then((gltf) => {
|
|
|
|
|
+ const base = gltf[0];
|
|
|
|
|
+ if (base) {
|
|
|
|
|
+ base.name = this.baseName;
|
|
|
|
|
+ this.group.add(base);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn(`[nitrogen-flex] 基底模型 ${this.baseName} 加载失败`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.unitTemplates = [];
|
|
|
|
|
+ for (let i = 0; i < this.unitNames.length; i++) {
|
|
|
|
|
+ const unit = gltf[i + 1];
|
|
|
|
|
+ if (!unit) {
|
|
|
|
|
+ console.warn(`[nitrogen-flex] 注氮机模型 ${this.unitNames[i]} 加载失败`);
|
|
|
|
|
+ this.unitTemplates.push(null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ unit.name = 'nitrogenUnitTpl' + i;
|
|
|
|
|
+ this.unitTemplates.push(unit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.loaded = true;
|
|
|
|
|
+ resolve(null);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((e) => {
|
|
|
|
|
+ console.error('[nitrogen-flex] 模型加载异常', e);
|
|
|
|
|
+ this.loaded = true;
|
|
|
|
|
+ resolve(null);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 探测「机体」在模型内的位置。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 每个 ele 模型除了机体,还含一条从机体延伸到管汇的长管路,
|
|
|
|
|
+ * 整体包围盒的中心会被管路拉偏,因此不能直接用整体包围盒做气泡锚点。
|
|
|
|
|
+ * 这里按尺寸特征找机体主箱体:X 宽 1.0~2.0 且 Y 高 > 0.4 的网格。
|
|
|
|
|
+ */
|
|
|
|
|
+ private computeMachineAnchor(unit: THREE.Object3D) {
|
|
|
|
|
+ unit.updateMatrixWorld(true);
|
|
|
|
|
+
|
|
|
|
|
+ const meshes: THREE.Mesh[] = [];
|
|
|
|
|
+ unit.traverse((o) => {
|
|
|
|
|
+ if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ let bestBox: THREE.Box3 | undefined;
|
|
|
|
|
+ let bestWidth = -1;
|
|
|
|
|
+ for (const mesh of meshes) {
|
|
|
|
|
+ const box = new THREE.Box3().setFromObject(mesh);
|
|
|
|
|
+ if (box.isEmpty()) continue;
|
|
|
|
|
+ const s = box.getSize(new THREE.Vector3());
|
|
|
|
|
+ if (s.x > 1.0 && s.x < 2.0 && s.y > 0.4 && s.x > bestWidth) {
|
|
|
|
|
+ bestBox = box;
|
|
|
|
|
+ bestWidth = s.x;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (bestBox) {
|
|
|
|
|
+ const center = bestBox.getCenter(new THREE.Vector3());
|
|
|
|
|
+ return { x: center.x, y: bestBox.max.y, z: center.z };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 回退:整体包围盒中心(仅在识别失败时使用)
|
|
|
|
|
+ const box = new THREE.Box3().setFromObject(unit);
|
|
|
|
|
+ const center = box.getCenter(new THREE.Vector3());
|
|
|
|
|
+ return { x: center.x, y: box.max.y, z: center.z };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 相机参数:按「包围球 + 相机 FOV + 画面宽高比」推导取景距离。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 本场景宽而扁(约 8.58 × 1.12 × 1.78),横向才是真正的约束,
|
|
|
|
|
+ * 因此以「宽度刚好占满画面 WIDTH_FILL 比例」定距离;
|
|
|
|
|
+ * 再设一个包围球拟合的下限,避免超宽屏时相机贴得过近导致上下被裁。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 相比写死坐标,这样在不同窗口尺寸下取景一致,不会屏幕一宽模型就变小。
|
|
|
|
|
+ */
|
|
|
|
|
+ getCameraConfig() {
|
|
|
|
|
+ const box = new THREE.Box3().setFromObject(this.group);
|
|
|
|
|
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
|
|
|
+ const center = sphere.center;
|
|
|
|
|
+ const radius = sphere.radius || 1;
|
|
|
|
|
+ const size = box.getSize(new THREE.Vector3());
|
|
|
|
|
+
|
|
|
|
|
+ const cam = this.model?.camera;
|
|
|
|
|
+ const vFov = (((cam?.fov as number) ?? 50) * Math.PI) / 180;
|
|
|
|
|
+ const aspect = (cam?.aspect as number) || 1.8;
|
|
|
|
|
+ const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
|
|
|
|
|
+
|
|
|
|
|
+ // 按宽度装下
|
|
|
|
|
+ const dByWidth = size.x / 2 / Math.tan(hFov / 2) / this.cameraWidthFill;
|
|
|
|
|
+ // 下限:包围球拟合再乘系数,保证纵向也不会被裁
|
|
|
|
|
+ const dFloor = (radius / Math.sin(Math.min(vFov, hFov) / 2)) * this.cameraMinFactor;
|
|
|
|
|
+ const distance = Math.max(dByWidth, dFloor);
|
|
|
|
|
+
|
|
|
|
|
+ const dir = new THREE.Vector3(this.cameraDir.x, this.cameraDir.y, this.cameraDir.z).normalize();
|
|
|
|
|
+ const pos = center.clone().add(dir.multiplyScalar(distance));
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ camera: { x: pos.x, y: pos.y, z: pos.z },
|
|
|
|
|
+ target: { x: center.x, y: center.y, z: center.z },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 设置机数并重建机体。
|
|
|
|
|
+ * 模型为原位摆放:克隆后不做任何缩放/位移,机位与管路对接由模型自身保证。
|
|
|
|
|
+ */
|
|
|
|
|
+ setUnitCount(num: number) {
|
|
|
|
|
+ if (!this.loaded) return;
|
|
|
|
|
+ const count = Math.max(0, Math.min(this.unitNames.length, Number(num) || 0));
|
|
|
|
|
+ this.nitrogenNum = count;
|
|
|
|
|
+
|
|
|
|
|
+ this.clearCssText();
|
|
|
|
|
+ this.unitGroups.forEach((u) => this.group.remove(u));
|
|
|
|
|
+ this.unitGroups = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
|
|
+ const tpl = this.unitTemplates[i];
|
|
|
|
|
+ if (!tpl) {
|
|
|
|
|
+ console.warn(`[nitrogen-flex] 第 ${i + 1} 台注氮机模型缺失,跳过`);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const unit = tpl.clone(true);
|
|
|
|
|
+ unit.name = 'nitrogenUnit' + i;
|
|
|
|
|
+ // 原位摆放:保持模型内置机位,杜绝错位与管路脱开
|
|
|
|
|
+ unit.position.set(0, 0, 0);
|
|
|
|
|
+ unit.scale.set(1, 1, 1);
|
|
|
|
|
+ this.group.add(unit);
|
|
|
|
|
+ this.unitGroups.push(unit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 整体垂直/深度微调(默认 0,即模型设计位置)
|
|
|
|
|
+ if (this.unitLiftY || this.unitOffsetZ) {
|
|
|
|
|
+ this.unitGroups.forEach((u) => {
|
|
|
|
|
+ u.position.set(0, this.unitLiftY, this.unitOffsetZ);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setModalCenter(this.group);
|
|
|
|
|
+ this.addCssText();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 挂载数据气泡:三台机的监测框 + 下风侧监测框 */
|
|
|
|
|
+ addCssText = () => {
|
|
|
|
|
+ this.unitGroups.forEach((unit, i) => {
|
|
|
|
|
+ if (!unit) return;
|
|
|
|
|
+ if (unit.getObjectByName('monitorNitrogenText')) return;
|
|
|
|
|
+ const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
|
|
|
|
|
+ if (!element) return;
|
|
|
|
|
+
|
|
|
|
|
+ const anchor = this.computeMachineAnchor(unit);
|
|
|
|
|
+ const sprite = new CSS3DSprite(element);
|
|
|
|
|
+ sprite.name = 'monitorNitrogenText';
|
|
|
|
|
+ // CSS3DObject 构造会把元素设为 pointerEvents:'auto',会遮挡轨道控制器拖拽
|
|
|
|
|
+ element.style.pointerEvents = 'none';
|
|
|
|
|
+ sprite.scale.set(this.spriteScale, this.spriteScale, this.spriteScale);
|
|
|
|
|
+ // 气泡挂在机顶上方(机体位置由模型内部布局决定,故按探测结果定位)
|
|
|
|
|
+ sprite.position.set(anchor.x, anchor.y + this.spriteTopOffset, anchor.z);
|
|
|
|
|
+ unit.add(sprite);
|
|
|
|
|
+ this.cssSprites.push(sprite);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ this.attachDownWindMonitor();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 挂载「下风侧监测」框。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 若不显式挂载,该元素会以未定位状态留在 DOM 中,表现为跑到页面左上角。
|
|
|
|
|
+ * 这里按基底包围盒归一化锚点定位,默认放在管路的下风侧一端。
|
|
|
|
|
+ */
|
|
|
|
|
+ private attachDownWindMonitor() {
|
|
|
|
|
+ if (this.group.getObjectByName('downWindMonitor')) return;
|
|
|
|
|
+ const element = document.getElementById('downWindMonitor') as HTMLElement;
|
|
|
|
|
+ if (!element) return;
|
|
|
|
|
+
|
|
|
|
|
+ const base = this.group.getObjectByName(this.baseName);
|
|
|
|
|
+ if (!base) return;
|
|
|
|
|
+ const box = new THREE.Box3().setFromObject(base);
|
|
|
|
|
+ const { nx, ny, nz } = this.downWindAnchor;
|
|
|
|
|
+ const x = box.min.x + (box.max.x - box.min.x) * nx;
|
|
|
|
|
+ const y = box.min.y + (box.max.y - box.min.y) * ny;
|
|
|
|
|
+ const z = box.min.z + (box.max.z - box.min.z) * nz;
|
|
|
|
|
+
|
|
|
|
|
+ const sprite = new CSS3DSprite(element);
|
|
|
|
|
+ sprite.name = 'downWindMonitor';
|
|
|
|
|
+ element.style.pointerEvents = 'none';
|
|
|
|
|
+ sprite.scale.set(this.spriteScale, this.spriteScale, this.spriteScale);
|
|
|
|
|
+ sprite.position.set(x, y, z);
|
|
|
|
|
+ // 挂在 group 上而非某台机体,避免机体变换影响该框
|
|
|
|
|
+ this.group.add(sprite);
|
|
|
|
|
+ this.cssSprites.push(sprite);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ clearCssText = () => {
|
|
|
|
|
+ this.cssSprites.forEach((s) => {
|
|
|
|
|
+ s.parent?.remove(s);
|
|
|
|
|
+ });
|
|
|
|
|
+ this.cssSprites = [];
|
|
|
|
|
+ // 兜底清理可能残留的精灵节点
|
|
|
|
|
+ this.unitGroups.forEach((u) => {
|
|
|
|
|
+ const old = u.getObjectByName('monitorNitrogenText');
|
|
|
|
|
+ if (old) u.remove(old);
|
|
|
|
|
+ });
|
|
|
|
|
+ const dw = this.group.getObjectByName('downWindMonitor');
|
|
|
|
|
+ if (dw) this.group.remove(dw);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 与既有类保持一致的接口(本模型无需额外处理) */
|
|
|
|
|
+ setModalPosition() {}
|
|
|
|
|
+
|
|
|
|
|
+ resetModel() {}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 布局/取景微调入口(默认值即当前效果,通常无需调整)。
|
|
|
|
|
+ * 仅在实物效果需要微调时使用,避免写死进结构里。
|
|
|
|
|
+ */
|
|
|
|
|
+ setLayoutTuning(opts: {
|
|
|
|
|
+ unitLiftY?: number;
|
|
|
|
|
+ unitOffsetZ?: number;
|
|
|
|
|
+ downWindAnchor?: { nx: number; ny: number; nz: number };
|
|
|
|
|
+ cameraWidthFill?: number;
|
|
|
|
|
+ spriteTopOffset?: number;
|
|
|
|
|
+ }) {
|
|
|
|
|
+ if (opts.unitLiftY !== undefined) this.unitLiftY = opts.unitLiftY;
|
|
|
|
|
+ if (opts.unitOffsetZ !== undefined) this.unitOffsetZ = opts.unitOffsetZ;
|
|
|
|
|
+ if (opts.downWindAnchor !== undefined) this.downWindAnchor = opts.downWindAnchor;
|
|
|
|
|
+ // 取景与气泡高度是纯渲染参数,改完直接重排即可生效
|
|
|
|
|
+ if (opts.cameraWidthFill !== undefined) this.cameraWidthFill = opts.cameraWidthFill;
|
|
|
|
|
+ if (opts.spriteTopOffset !== undefined) this.spriteTopOffset = opts.spriteTopOffset;
|
|
|
|
|
+ this.setUnitCount(this.nitrogenNum);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ destroy() {
|
|
|
|
|
+ if (this.model) {
|
|
|
|
|
+ this.clearCssText();
|
|
|
|
|
+ this.model.clearGroup(this.group);
|
|
|
|
|
+ this.unitTemplates = [];
|
|
|
|
|
+ this.unitGroups = [];
|
|
|
|
|
+ this.model = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default NitrogenFlex;
|