文章
问答
冒泡
React+Three 实现视角切换放大展示+ 结合接口数据对模型进行展示交互

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

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();
  }

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

动画1.gif


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

//util.ts
export 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 生成标签标记模型。

image.png

通过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更新注册。


最终效果如图。

动画2.gif

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

three

关于作者

却黑
社恐
获得点赞
文章被阅读