🌓
搜索
 找回密码
 立即注册

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

admin 2024-4-5 11:34:47 32008

之前主要实现的对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:接下来尝试对于点击模型,切换场景进入内部暂时的需求尝试。

扫一扫

113246.jpg
随机推荐

最新主题

0 回复

高级模式
游客
返回顶部