之前主要实现的对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();//必须要有updatathis.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(),其中要传入的位置信息,可以使用控制器的回调标记出来。其中为了统一参照物,统一使用世界坐标来记录位置。
//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);}
场景标注标签信息的主要思路为:
HTML元素创建标签
CSS2模型对象CSS2Object把html转换成模型对象
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 (<Popoverkey={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:接下来尝试对于点击模型,切换场景进入内部暂时的需求尝试。

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