之前主要实现的对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()
,其中要传入的位置信息,可以使用控制器的回调标记出来。其中为了统一参照物,统一使用世界坐标来记录位置。
//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 生成标签标记模型
通过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 (
<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:接下来尝试对于点击模型,切换场景进入内部暂时的需求尝试。
本文暂时没有评论,来添加一个吧(●'◡'●)