编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

React+Three.js 实现视角切换放大展示并结合接口数据对模型进行展示交互

wxchong 2024-07-06 01:03:55 开源技术 11 ℃ 0 评论

之前主要实现的对Three的相关功能做了封装以及基础的使用,接下来继续新功能的开发:


基于React+Umi4+Three.js 实现3D模型数据可视化

github: https://github.com/Gzx97/umi-three-demo/tree/dev

实现选中某部位,视角切换的模型交互

通过threejs的基础概念可知,视角切换的主要原理就是改变相机camera的摆放位置,但是突然变更相机的位置视角切换的会很突兀,这个时候我们就需要来补足切换视角的动画效果(补间动画)。针对这个效果,可以使用一个库 tweenjs ,是一个由JavaScript语言编写的补间动画库,如果需要tweenjs辅助你生成动画,对于任何前端web项目,你都可以选择tweenjs库。

这个库Threejs的包里面默认带了可以直接引用:

import TWEEN, { Tween } from "three/examples/jsm/libs/tween.module.js";

Tween.js的基本api介绍:

const tween=new TWEEN.Tween(position);//初始化动画变量 tween.to({ x:150 },8000);//设置下一个状态量 tween.easing(TWEEN.Easing.Sinusoidal.InOut);//设置过渡效果 tween.onUpdate(callback);//更新回调函数 tween.start();//启动动画
function animate() { // [...] TWEEN.update(); requestAnimationFrame(animate); }

在Viewer中,新增初始化Tween的函数,由于我们想实现的是相机摆放位置的切换,传入相机的position:

/** * 初始化补间动画库tween */ public initCameraTween() { if (!this.camera) return; this.tween = new Tween(this.camera.position); }
/** * 添加补间动画 * @param targetPosition * @param duration */ public addCameraTween( targetPosition = new THREE.Vector3(1, 1, 1), duration = 1000 ) { this.initCameraTween(); this.tween.to(targetPosition, duration); this.tween.start(); }
private initViewer() { ... this.raycaster = new Raycaster(); this.mouse = new Vector2(); const animate = () => { if (this.isDestroy) return; requestAnimationFrame(animate); TWEEN.update();//必须要有updata this.updateDom(); this.renderDom(); // 全局的公共动画函数,添加函数可同步执行 this.animateEventList.forEach((event) => { // event.fun && event.content && event.fun(event.content); if (event.fun && event.content) { event.fun(event.content); } }); }; animate(); }

封装好接下来就可以到页面中使用这个方法了,我们先点击模型的椅子,把视角切换到放大看椅子。先看效果:

首先我们先把要点击的模型中的目标遍历出来,然后使用方法viewer?.addCameraTween![动画1.gif](https://img.ithere.net/article/article/image/2024/3/28/PxDMCntaBKQvOHrRNNxzaxNuMClpNERA.gif)(),其中要传入的位置信息,可以使用控制器的回调标记出来。其中为了统一参照物,统一使用世界坐标来记录位置。

//util.tsexport function checkNameIncludes(obj: Object3D, str: string): boolean { if (obj.name.includes(str)) { return true; } else { return false; }}//Viewer 控制器的监听回调 this.controls.addEventListener("change", () => { // console.log(this.camera); this.renderer.render(this.scene, this.camera); }); const checkIsChair = (obj: THREE.Object3D): boolean => { return checkNameIncludes(obj, "chair"); };//index//点击监听中把椅子相关的模型过滤出来 const onMouseClick = (intersects: THREE.Intersection[]) => { const viewer = viewerRef.current; if (!intersects.length) return; const selectedObject = intersects?.[0].object || {}; const isChair = checkIsChair(selectedObject); if (isChair) { console.log(selectedObject); const worldPosition = new THREE.Vector3(); console.log(selectedObject.getWorldPosition(worldPosition)); viewer?.addCameraTween(new THREE.Vector3(0.05, 0.66, -2.54)); } else { viewer?.addCameraTween(new THREE.Vector3(4, 2, -3)); } };

以上就是实现视角切换功能的核心方法。

使用CSS2DRenderer 生成标签标记模型

通过CSS2DRenderer.js可以把HTML元素作为标签标注三维场景

跟以上思路一样,在Viewer中注册好方法,

private initViewer() { ... this.initCss2Renderer(); ...}private initCss2Renderer() { this.css2Renderer = new CSS2DRenderer();}
/** * 添加2D标签 */public addCss2Renderer() { if (!this.css2Renderer) return; this.css2Renderer.render(this.scene, this.camera); this.css2Renderer.setSize(1000, 1000); this.css2Renderer.domElement.style.position = "absolute"; this.css2Renderer.domElement.style.top = "0px"; this.css2Renderer.domElement.style.pointerEvents = "none"; this.viewerDom.appendChild(this.css2Renderer?.domElement);}

场景标注标签信息的主要思路为:

    1. HTML元素创建标签

    2. CSS2模型对象CSS2Object把html转换成模型对象

    3. CSS2渲染器css2Renderer渲染到对应的场景中

在React中我们先把标签组件写出来,然后把ref传递出来给父组件使用,以便于可以获取到标签的dom。其中我们想对模型中每个设备标记标签,所以把模型设备的数量收集出来,生成对应的html标签。代码核心功能如下:完整代码在github查看。

 /** 存储标签ref */ const tagRefs = useRef<Object3DExtends[]>([]); /** 创建CSS2DObject标签 */ const createTags = (dom: HTMLElement, info: any) => { const viewer = viewerRef.current; const show = info?.visible; if (!show) { let tag = undefined as CSS2DObject | undefined; viewer?.scene?.traverse((child) => { if (child instanceof CSS2DObject && child?.name === info?.name) { tag = child; } }); tag && viewer?.scene.remove(tag); return; } viewer?.addCss2Renderer(); const TAG = new CSS2DObject(dom); const targetPosition = info?.position; TAG.position.set( targetPosition?.x, targetPosition?.y + 0.5, targetPosition?.z ); TAG.name = info.name; let hasTag = false; viewer?.scene?.traverse((child) => { if (child instanceof CSS2DObject && child.name === info.name) { hasTag = true; } }); !hasTag && viewer?.scene.add(TAG); // console.log(viewer?.scene); };
// 加载模型 const initModel = () => { modelLoader.loadModelToScene("/models/datacenter.glb", (baseModel) => { console.log(baseModel); model.traverse((item) => { if (checkIsRack(item)) { rackList.push(item);//收集设备的模型信息 } }); setRackList(rackList); const viewer = viewerRef.current; // 将 rackList 中的机架设置为 viewer 的射线检测对象 viewer?.setRaycasterObjects([...allList]); // viewer.setRaycasterObjects([...rackList, ...chairList]); }); }; /** 监听rackInfoList更新标签 */ useEffect(() => { console.log("监听rackInfoList更新标签", deviceListData); const viewer = viewerRef.current; let showNames = [] as string[]; let CSS2DObjectList = [] as CSS2DObject[]; tagRefs?.current?.map((item, index) => { createTags(item?.dom as HTMLElement, item.addData); if (item?.addData?.visible) { showNames.push(item?.name); } }); }, [deviceListData]);//...renderhtml标签{rackList?.map((item, index) => { return ( <Popover key={item?.name} ref={(el) => (tagRefs.current[index] = { dom: el, ...item } as Object3DExtends) } viewer={viewerRef.current} show={item?.addData?.visible} // data={popoverData} /> ); })}

其实光生成标签很容易,接下来我们试试标签的交互功能设计。

使用umi的mock功能,自己模拟一下接口数据请求

import { defineMock } from "umi";
type DeviceData = { id: string; name: string; warn?: boolean; position?: { top: number; left: number }; [key: string]: any;};
let deviceDatas: DeviceData[] = [ { id: "1", name: "rackA_1", warn: true }, { id: "2", name: "rackA_2", warn: false }, { id: "3", name: "rackA_3", warn: false }, { id: "4", name: "rackA_4", warn: false }, { id: "5", name: "rackA_5", warn: false }, { id: "11", name: "rackA_6", warn: true }, { id: "12", name: "rackA_7", warn: false }, { id: "13", name: "rackA_8", warn: false }, { id: "14", name: "rackA_9", warn: false }, { id: "15", name: "rackA_10", warn: false }, { id: "6", name: "rackB_6", warn: true }, { id: "7", name: "rackB_7", warn: true }, { id: "8", name: "rackB_8", warn: false }, { id: "9", name: "rackB_9", warn: true }, { id: "10", name: "rackB_1", warn: false }, { id: "16", name: "rackB_2", warn: true }, { id: "17", name: "rackB_3", warn: true }, { id: "18", name: "rackB_4", warn: false }, { id: "19", name: "rackB_5", warn: true }, { id: "20", name: "rackB_10", warn: false },];
export default defineMock({ "GET /api/getDeviceDatas": (req, res) => { res.send({ status: "ok", data: deviceDatas, }); }, "POST /api/getDeviceDatas/:id": (req, res) => { let id = `${req.params.id}`; const newDeviceDatas = deviceDatas?.map((item) => { if (item?.id === id) { return { ...item, warn: true }; } return { ...item, warn: false }; }); res.send({ status: "ok", data: newDeviceDatas }); },});

在页面中请求该接口

/** 获取mock数据 */ const { data: deviceDatas, run: queryDeviceDatas } = useRequest( (id) => { return axios .post(`/api/getDeviceDatas/${id}`) .then((res) => res.data?.data); }, { manual: true, } );

我们要把接口信息根据name与模型中的信息做匹配,并且把信息插入到对应的模型中,以便于交互时候使用。

这个需求由于需要频繁修改state,所以为了节约代码复杂度,直接设计成使用react的useReducer来操作数据。设计时候根据实际情况暂时分为一下几种类型:

const [deviceListData, dispatchDeviceListData] = useReducer( ( state: Object3DExtends[], action: { type: "OPERATE" | "INIT" | "ADD_DATA"; initData?: Object3DExtends[]; addData?: ModelExtendsData[]; operateData?: Object3DExtends; } ): Object3DExtends[] => { const { type, initData, addData, operateData } = action; switch (type) { case "INIT": if (initData) { return initData; } break; case "ADD_DATA": return state?.map((rack) => { const found = addData?.find((item) => item.name === rack.name); if (found) { const worldPosition = new THREE.Vector3(); // 获取模型在世界坐标系中的位置 Object.assign(rack, { addData: { ...found, position: rack.getWorldPosition(worldPosition), //获取世界坐标 visible: found?.visible ?? false, }, }); return rack; } return rack; }) as Object3DExtends[]; case "OPERATE": console.log(operateData); return state?.map((model) => { if (model.name === operateData?.name) { Object.assign(model, { addData: operateData?.addData }); } return model; }) as Object3DExtends[]; default: return [...state]; } return [...state]; }, [] );

其中要注意的是,插入接口信息到模型中,我用的是Object.assign 合并对象的形式,而不是传统的解构赋值,因为后面我们要频繁的遍历模型,所以保留原有的object数据引用地址。

需求:数据报警的时候设备弹框提示:

/** 根据接口数据为模型添加信息 */ useEffect(() => { const newData = deviceDatas?.map((data: ModelExtendsData) => { if (data?.warn) { return { ...data, visible: true }; } return { ...data }; }); dispatchDeviceListData({ type: "ADD_DATA", addData: newData }); }, [deviceDatas]); /** 执行报警操作 */ useEffect(() => { deviceListData?.forEach((item) => { if (item?.addData?.warn) { changeWarningColor(item); } else { changeOriginColor(item); } }); }, [deviceListData]);
/** 监听rackInfoList更新标签 */ useEffect(() => { console.log("监听rackInfoList更新标签", deviceListData); const viewer = viewerRef.current; let showNames = [] as string[]; let CSS2DObjectList = [] as CSS2DObject[]; tagRefs?.current?.map((item, index) => { createTags(item?.dom as HTMLElement, item.addData); if (item?.addData?.visible) { showNames.push(item?.name); } }); }, [deviceListData]);

其中对于报警数据的标红实现也是大致一个思路。也可以在监听鼠标点击事件中,控制弹框的显示隐藏

const onMouseClick = (intersects: THREE.Intersection[]) => { const viewer = viewerRef.current; if (!intersects.length) return; const selectedObject = intersects?.[0].object || {}; const isChair = checkIsChair(selectedObject); const rack = findParent(selectedObject, checkIsRack); if (rack) { updateRackInfo(rack.name); }//... }; const updateRackInfo = (name: string) => { if (!name) { return; } const sourceData = _.find(deviceListData, { name: name }); _.set(sourceData!, "addData.visible", !sourceData?.addData?.visible); dispatchDeviceListData({ type: "OPERATE", operateData: sourceData }); }; /** 需要监听rackInfoList更新监听点击事件的函数 */ useEffect(() => { if (!viewerRef.current) return; const viewer = viewerRef.current; viewer.emitter.off(Event.click.raycaster); //防止重复监听 viewer?.emitter.on(Event.click.raycaster, (list: THREE.Intersection[]) => { onMouseClick(list); }); }, [deviceListData, viewerRef]);

注意监听事件需要根据rackInfoList更新注册。

最终效果如图

//TODO:接下来尝试对于点击模型,切换场景进入内部暂时的需求尝试。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表