前言
首先写这篇文章可能不太适合非GIS领域的同学阅读(当然感兴趣的同学也可以瞄上两眼),起因是最近做了一个偏向于GIS方向相关的项目(后面会说到),回头看感触还是颇多,然后想着又有东西写了。。。GIS指的就是地理信息系统,它虽然可以说是属于前端,但我个人认为它可以自立门派了。原因是因为我做了这么多关于GIS项目后发现想做好这个需要你有很好的JS基础知识+专业知识(地理信息方面),最重要的还是后者,如果是没有这方面的知识很难深入进去,所以我也算是半道子入的门(^__^) 我觉得我应该跟大多数同学一样都属于计算机专业的前端。那下面就先聊一聊标题中的Cesium吧。
Cesium是一个跨平台的开源JavaScript库,它基于WebGL技术,用于创建三维地球和地图可视化应用程序。用官网的话来说就是:
Cesium 是软件应用程序的开放平台,旨在释放 3D 数据的力量。
跟它类似的库还有很多,像Leaflet、Openlayers等,但是并没有Cesium强大,它在地图库中可以说是数一数二的了。如果我说这句话没有让你有个更直观的感受,那可以看下这个示例网站感受一下。
下面开始进入正题,先聊聊我做这个项目的背景。
项目背景
该项目是通过采用多视角的摄像机对一些变电站场地中的设备进行摄像机智能化改造的软件系统,以此提高现场人员巡检的效率。
在前期勘察的过程中,摄像机的巡视点位的选取是依靠设计人员的主观经验而定,存在巡视点的空间位置、数量是否科学合理,无法得到有力证实的问题,主要体现在以下几个方面:
- 摄像机布点不尽合理,部分重要场地、设备未覆盖;
- 在每个间隔特点位置按功能布点,存在一定的重叠问题,经济性较差;
- 在设计阶段缺乏有效的摄像机布点手段,设计人员设计效率较低;
针对以上几个方面呢这个系统就应运而生了,下面就开始说下里面我总结的比较有意思的一些功能点吧。
单体绘制
所谓单体绘制指的就是对设备进行"单体化"操作,再直白一点就是把场景中的一些设备标识出来,方便单独用来进行分析。
上图就是一个小型的变电站场景,这里就不对所有设备一一进行单体化了,只对场景中的主变区进行单体化的绘制,如下图所示。
这就是最终单体化的效果,下面主要说下绘制的过程。
首先我们需要一个按钮来触发绘制
js
// 模板
<template>
<el-button @click="collectConfirm">开始绘制</el-button>
</template>
<script>
import drawLiti from '@/common/plane/drawLiti.js'
export default {
methods:{
collectConfirm(){
let arg = {
viewer: window.viewerEarthPlaning,
opt: {
color: 'yellow',
opa: 0.5,
},
callback: (position, height,flag) => {
// do somthing ...(可以在此处根据传递过来的坐标点进行面的绘制)
},
};
// 生成绘制立方体的实例对象
let rectangle = new drawLiTi(arg);
rectangle.startCreate();
}
}
}
</script>
在collectConfirm
事件处理函数中,我们可以通过专门声明一个用来绘制立方体的类即drawLiti
,引入之后通过new
生成一个实例对象并传递相应的参数来完成整个绘制的流程。其中arg
是初始化Cesium时的实例对象,opt
是一些用户可配置的信息,callback
是指结束绘制时的回调函数,在里面可以获取到绘制完毕的一些位置信息。通过调用类里面的startCreate
方法开启绘制。
根据上图可以看到主变区设备的绘制规则是点击四个点在四个不同方向上立起一个面,下面是drawLiti
类的代码。
js
export default class DrawPolygon {
constructor(arg) {
this.opt = arg.opt;
this.viewer = arg.viewer;
this.callback = arg.callback;
this._positions = []; // 保存绘制的点的坐标
this._entities_point=[]; // 保存绘制的点实体
//设置鼠标状态
this.viewer.enableCursorStyle = false;
this.viewer._element.style.cursor = 'crosshair';
}
//开始绘制
startCreate() {
var $this = this;
// 初始化Cesium事件句柄
this.handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
this.handler.setInputAction(function (evt) { //鼠标单击事件
// 将坐标转换为Cartesian3坐标
var cartesian = $this.getCatesian3FromPX(evt.position, evt.position)
// 统一高度,以第一个点击的点的高度为基准
if ($this._positions.length >= 1) {
let height = $this.cartesian3Point3($this._positions[0])[2];
let cartesian = $this.cartesian3Point3(cartesian);
$this._positions.push(Cesium.Cartesian3.fromDegrees(cartesian[0], cartesian[1], height));
} else if ($this._positions.length == 0) {
$this._positions.push(cartesian)
}
// 绘制点
$this.createPoint(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
this.handler.setInputAction(function () { // 鼠标右键事件
if ($this._positions.length == 0) return;
if ($this._positions.length == 4) {
// 集体加载/绘制完四个点的标识,否则绘制后会重复绘制四次
let flag = false;
for (let i = 0; i < $this._positions.length; i++) {
const sumArr = $this._positions.slice(i, i + 2);
if (i == 3) {
sumArr.push($this._positions[0])
flag = true;
}
// 计算每两个点对应高度的点
let startPoint = $this.cartesian3Point3(sumArr[0]);
let endPoint = $this.cartesian3Point3(sumArr[1]);
let cartesian4 = Cesium.Cartesian3.fromDegrees(startPoint[0], startPoint[1], startPoint[2] + 4)
let cartesian3 = Cesium.Cartesian3.fromDegrees(endPoint[0], endPoint[1], endPoint[2] + 4);
let height = (startPoint[2] + 4).toFixed(3);
// 重新组装点位顺序
let positions = [];
positions.push(sumArr[0])
positions.push(cartesian4)
positions.push(sumArr[1])
positions.push(cartesian3)
//回调函数画完以后做什么事情
if (typeof $this.callback == "function") {
$this.callback(positions, height, flag);
}
}
//设置鼠标状态
$this.viewer._element.style.cursor = 'default';
$this.viewer.enableCursorStyle = true;
$this.destroy();
} else {
$this.instance.$message.warning("需要四个点")
$this._positions = [];
}
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
}
cartesian3Point3(position) {
const c = Cesium.Cartographic.fromCartesian(position);
const lon = Cesium.Math.toDegrees(c.longitude);
const lat = Cesium.Math.toDegrees(c.latitude);
return [lon, lat, c.height];
}
//创建点
createPoint(cartesian) {
var point = this.viewer.entities.add({
position: cartesian,
point: {
pixelSize: 5,
color: Cesium.Color.fromCssColorString(this.opt.color3),
}
})
this._entities_point.push(point)
return point
}
//销毁事件
destroy() {
if (this.handler) {
if (this.viewer._element.style.cursor == 'crosshair')
this.viewer._element.style.cursor = 'default';
this.handler.destroy();
this.handler = null;
}
}
// 用于转换Cartesian3坐标
getCatesian3FromPX(px, st) {
var cartesian = this.viewer.scene.pickPosition(px);
if (!cartesian) {
const ellipsoid = this.viewer.scene.globe.ellipsoid;
cartesian = this.viewer.scene.camera.pickEllipsoid(st, ellipsoid);
}
return cartesian;
}
}
然后我们就可以在callback
中获取到绘制完后四个不同方向上的四个点,因为一个面需要四个点的坐标才能进行显示出来,所以就通过点击四个点通过高度自动计算的方式巧妙的实现这一功能,下面来总结一下步骤。
- 首先收集一下第一个点的高度,用于保证后面所有点击的点都在同一高度;
- 基于第一个点的高度进行点之间的两两计算就可以得到每一个面的四个坐标;
- 声明一个标识用于在绘制完第四个点之后再统一进行坐标的返回,避免重复绘制的问题;
- 最后将面片坐标传递到绘制面的方法中(这一步实现方法有很多,因此没做描述)
智能布点
所谓智能布点还是以上面那张图为例,就是需要在每个面的正前方的正中间布置一个摄像机用来监视。
实现这个也比较简单,也是需要一些简单的计算:
- 通过循环四个面并获取到其前两个点的坐标;
- 获取两点之间的中点坐标;
- 计算中点坐标跟两侧坐标之间的朝向;
- 默认给出中点坐标以此朝向移动的距离(2.5米);
- 最后计算中点绕另一点旋转90度后的终点坐标;
js
handleAutoGeneration(){
for(let i = 0;i < allEntityList.length; i++){
let entity = allEntityList[i];
// 假设是用Primitive方式绘制的面
const positions = entity.geometryInstances.geometry._positions
// 两点之间的中点坐标
const midPoint = this.getCartesianMiddlePoint(positions[0], positions[2]);
// 两点之间的朝向
const direction = Cesium.Cartesian3.subtract(
midPoint,
positions[2],
new Cesium.Cartesian3()
);
// 中点坐标根据规则移动xx距离后的坐标 1.5m
const newPoint = this.translateByDirection(midPoint, direction, 2.5);
// 获取旋转90度后的点
const rotatePoint = this.rotatePoint(
Cesium.Math.toRadians(90),
midPoint,
newPoint
);
}
}
// 以下是一些辅助函数
// 获取两个Cartesian3坐标之间的中点坐标
getCartesianMiddlePoint(left, right) {
let midPoint = Cesium.Cartesian3.midpoint(left,right,new Cesium.Cartesian3());
return midPoint;
}
/**
* @description: 根据一个原点,向一个方向上平移多少米后,求得另一个点的坐标
* @param {Cartesian3} start 原点
* @param {Cartesian3} direction 起点指向终点的方向
* @param {Number} offset 平移距离,单位米
* @return {Cartesian3} 目标点
*/
translateByDirection(start, direction, offset) {
let normalize = Cesium.Cartesian3.normalize(
direction,
new Cesium.Cartesian3()
); //根据偏移量求偏移向量
let scalerNormalize = Cesium.Cartesian3.multiplyByScalar(
normalize,
offset,
new Cesium.Cartesian3()
);
return Cesium.Cartesian3.add(
start,
scalerNormalize,
new Cesium.Cartesian3()
);
}
//计算某一点绕另一点旋转radian后的终点坐标
rotatePoint(radian, startPoint, endPoint) {
let startCartographic = Cesium.Cartographic.fromCartesian(startPoint); //起点经纬度坐标
let endCartographic = Cesium.Cartographic.fromCartesian(endPoint); //终点经纬度坐标
//初始化投影坐标系
/*假设对图片上任意点a,绕一个坐标点o逆时针旋转angle角度后的新的坐标点b,有公式:
b.x = ( a.x - o.x)*cos(angle) - (a.y - o.y)*sin(angle) + o.x
b.y = (a.x - o.x)*sin(angle) + (a.y - o.y)*cos(angle) + o.y */
let webMercatorProjection = new Cesium.WebMercatorProjection(window.viewerEarthPlaning.scene.globe.ellipsoid);
let startMercator = webMercatorProjection.project(startCartographic); //起点墨卡托坐标
let endMercator = webMercatorProjection.project(endCartographic); //终点墨卡托坐标
//左边界线墨卡托坐标
let position_Mercator = new Cesium.Cartesian3(
(endMercator.x - startMercator.x) * Math.cos(radian) -
(endMercator.y - startMercator.y) * Math.sin(radian) +
startMercator.x,
(endMercator.x - startMercator.x) * Math.sin(radian) +
(endMercator.y - startMercator.y) * Math.cos(radian) +
startMercator.y,
startMercator.z
);
//左边界线经纬度坐标
let position_Cartographic =
webMercatorProjection.unproject(position_Mercator);
//左边界线笛卡尔空间直角坐标
let position_Cartesian3 = Cesium.Cartographic.toCartesian(
position_Cartographic.clone()
);
return position_Cartesian3;
},
其实rotatePoint
点的坐标就是我们需要的坐标了,将其绘制出来即可。
单体识别
有了摄像机的位置后下一步要做的就是可以查看有哪些面是在摄像机可视范围内的。做这个功能要考虑的因素有:
- 如何确定或者说获取可视范围内的面;
- 如何区分面的正面和反面;
第一个问题其实比较好解决,思路就是通过计算所有面的前面两个点分别与摄像机坐标点之间的距离(两点之间的距离),得出这个距离后再与摄像机本身的焦距距离(10米)作比较,小于则是在摄像机可视范围内,反之没在可视范围内。
js
// 获取摄像机范围内的所有面片
async getFocalEntity(startCartesian, focalDistance) {
let allCoverEntity = [];
for (let i = 0; i < allEntityList.length; i++) {
let entity = allEntityList[i];
let positions = entity.geometryInstances.geometry._positions;
// 取出前两个点
let point1 = positions[0];
let point2 = positions[2];
if (positions) {
// 获取两个点分别与摄像机坐标点之间的距离
let distance1 = Cesium.Cartesian3.distance(startCartesian, point1);
let distance2 = Cesium.Cartesian3.distance(startCartesian, point2);
if (distance1 < focalDistance && distance2 < focalDistance) {
// 可视距离内的所有面片
allCoverEntity.push(entity);
}
}
}
return allCoverEntity;
},
获取到可视范围内的所有面后接下来就需要进行正反面的区分,当两个面之间存在遮挡关系的时候,摄像机只能看到相对于摄像机正面的那个面,这也是为了模拟现场真实的情况。
- 首先需要重复智能布点时的步骤,在四个面片前面添加一个坐标点;
- 将每个坐标点与摄像机点位相连;
- 将每个面分为两个三角形,判断这条线是否穿过三角形,若有交点则视为有遮挡,该面不可视;
js
// 将摄像机坐标转为经纬度
let arr = this.cartesian3Point3(startCartesian);
// 将旋转后的点转为经纬度
let arr2 = this.cartesian3Point3(rotatePoint);
let isIntersecting = Cesium.IntersectionTests.lineSegmentTriangle(
Cesium.Cartesian3.fromDegrees(...arr1),
Cesium.Cartesian3.fromDegrees(...arr3),
allpositions[0],
allpositions[1],
allpositions[2],
false
);
// 如果为true代表摄像机的点和面片正方向上的点与面片上的线有交点即该摄像机在该面片的背面,应该被去除
if(isIntersecting){
// do somthing...(收集要被去除的面)
}
总结
总体写下来感觉还是比较麻烦的,但总归也是以一种经历,以此来记录当时我在黑夜中的挣扎,也是在时刻提醒自己消除恐惧最好的办法就是直面恐惧,加油!