如何使用esMap快速搭建室内场景

3,348 阅读12分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

背景介绍

这两天老板们又给我整活儿了,那天上午我还在赶当前的项目任务焦头烂额时,兄弟公司的领导突然来电邀我参加一个视频会议,会议上诉说了有一个重大项目要投标之类的重要程度,紧接着果不其然地给我分配了一个任务,在2天内完成停车场的3D场景演示页面开发,具体需求如下:

  1. 实现在网页上对停车场3D模型的展示,停车场有1-3层,提供CAD图纸;

  2. 实现停车场内部的灯具展示和控制,支持单独控制某个灯具,或者批量控制指定的灯具,灯具有相应的状态;

  3. 支持编辑修改灯具位置,并提供保存功能。

因为还有几天时间系统就要投标演示了,所以两天时间要搞定这些内容,这咋整?面对如此“无理”的需求,一开始我本来是想拒绝的,毕竟以我对webGL粗浅的认知,不做个十天半月肯定整不出啥东西。后来转念一想我没必要从零开始啊,这需求也不新,说不定互联网上早就有工具能够满足一部分需求。

Honeycam 2024-06-30 21-49-39.gif

经过了几个小时的AI+脑洞摸索,我终于找到一款非常合适的工具——esMap三维可视化引擎室内地图 ,这个平台提供了非常便捷的场景搭建服务、 在线代码演示页面、以及相对齐全的SDK文档方便开发者快速接入,最后一顿操作猛如虎,当天就把演示DEMO搭建好,并将整个操作录制成视频提交过去,大大超出了领导的预期。接下来详细跟大家讲讲这到底是怎么做到的。

前期准备

需求分析

把前面的客户需求“翻译”为功能需求,我们大概得到下面几个任务,接下来我们只要把这些任务逐个击破,就能顺利达成目标。

  1. 场景建模:支持将CAD图纸的.dwg文件直接转换成3D模型,并对模型进行二次编辑
  2. 动态标注:在模型上叠加灯具设备图标,能够根据位置数据动态加载和展示
  3. 可视化操作:点击每个设备图标弹出设备详情,并提供设备操作按钮(比如开灯、关灯、调光等),点击按钮后发送指令并根据指令回调更新图标状态

启动工程

  1. 登录esMap仿真云平台,并注册一个免费账号
  2. 官网演示包页面中获取基础的场景演示包,演示的工程包需要放到IIS、Tomcat、NodeJs等静态文件服务器下才能运行,这里我们用Node.js搭建
  3. 启动服务后,通过地址直接访问esMap中的页面示例即可,我们也可以直接在这里目录里创建自己的演示页面 parkingLot.html
    Untitled 1.png

实现步骤

1. 场景建模

  1. 登录平台账号,进入控制台页面
  2. 选择“室内三维场景 - 免费版场景 - 创建三维场景”,可以免费创建5个场景 %E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE_2024-06-30_170324.png
  3. 在弹窗区域选择导入停车场某一层的CAD图纸,新建三维场景提供2500㎡以内三维场景免费测试使用
    %E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE_2024-06-30_170449.png
  4. 导入成功后可以看见图纸,点击AI识别可以一键自动生成模型,免费版有场景面积限制
  5. 通过面板 编辑场景,可以添加各种静态标注、组件、模型 Honeycam_2024-06-30_17-10-58.gif
  6. 点击“提交”保存场景,回到“我的室内三维场景”,鼠标悬浮到每个场景面板右下角三个点图表,即可下载和编辑场景信息,我们把场景包下载下来。 %E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE_2024-06-30_171422.png
  7. 官网演示包页面中获取基础的场景演示包,将我们的场景包解压放到工程data目录中。
    Untitled 2.png
  8. 在刚刚创建的parkingLot.html中编写代码
    <html>
    ...
    <div id="map-container">
    </div>
    
    <script src="../lib/config.js"></script>
    <script src="../lib/esmap-3.0.min.js"></script>
    <script src="../lib/jquery-2.1.4.min.js"></script>
    
    <script type="text/javascript">
    
    const esmapID = '$场景目录名称';
    const container = document.getElementById('map-container');
    
    const map = new esmap.ESMap({
      mode: esmap.MapMode.Building,
      container: container, 
      mapDataSrc: '../data', //生命三维场景的目录位置
      token: "$场景的唯一TOKEN" //token从官网场景信息里获取
    });
    
    map.on('mapClickNode', (event)=>{
        mapCoord = event.hitCoord || null;
        console.log(JSON.stringify(mapCoord))
    })
    
    </script>
    
  9. 如此我们就可以快速获得1个目标3D场景了 Untitled 3.png

2. 动态标注

在模型上叠加灯具设备图标,能够根据位置数据动态加载和展示。添加标注有2种方法,可以在场景编辑页面中直接插入标注,然后随场景一起导出,不过这个方法处理的标注无法进行交互,仅适合作为场景的装饰的一部分,比如“电梯、安全通道、楼梯”之类的标识

  1. 动态标注的实现也很简单,esMap提供了esmap.ESTextMarker ,我们只要创建一个用于放ESTextMarker的专属图层,然后根据获取到所有标注的具体位置逐个创建实例就行了。

    
    // 灯具状态图标
    const ICON_LIGHT_ON = '/data/parkinglot1/imagelabel/light_on.png'
    const ICON_LIGHT_OFF = '/data/parkinglot1/imagelabel/light_off.png'
    
    const data = [
      {
        "id": 2,
        "name": "灯具",
        "coord": {
          "x": 12957438.625300206,
          "y": 4851944.339834387
        }
      }, ...
    ]
    
    function addMarkers(data){
      const building = map.getBuilding();
      const floorLayer = building.getFloor(1);
      // 获取或创建一个用于放ESTextMarker的专属图层
      let layer = floorLayer.getOrCreateLayerByName('dynamic', esmap.ESLayerType.TEXT_MARKER);
    
      // 添加标注
      data.forEach((item,index)=>{
        const {coord, id, name} = item
        const marker = new esmap.ESTextMarker({
          id,
          name,
          x: coord.x, // 空间坐标x
          y: coord.y, // 空间坐标y
          height: 2.5, // 离地高度
          image: ICON_LIGHT_OFF,
          imageSize: 64,
          scale: 1,
          fixedSize: true, //是否固定大小,默认为true,可选参数
        })
        layer.addMarker(marker)
        // 缓存标注以便控制其状态
        markerList.push(marker)
      })
    
      floorLayer.addLayer(layer)
    }
    
    addMarkers(data)
    
  2. 销毁动态标注,就是直接调用ESTextMarker专属图层的removeAll方法

    // 清空标注
    function  clearMarkers(){
      const building = map.getBuilding();
      const floorLayer = building.getFloor(1);
      floorLayer.getLayersByNames('dynamic',(layer)=>{
        if(layer){
          // 图层方法
          layer.removeAll()
          // 清空数据
          markerList.splice(0, markerList.length)
        }
      })
    }
    
  3. 有盆友可能会好奇设备的位置数据从哪来的。如果是大批量的数据,可以通过在场景中标定原点的坐标和单位长度动态计算出来;不过因为仅仅作为演示使用数量不多,就直接人工处理了,其实是我根据建筑图纸人肉点击地图获取到的。 Honeycam_2024-06-30_18-23-58.gif

  4. 最终实现效果如下所示 Honeycam_2024-06-30_18-34-09.gif

3. 可视化操作

点击每个设备图标弹出设备详情,并提供设备操作按钮,点击按钮后发送指令并根据指令回调更新图标状态。这里涉及到单灯操作和批量操作,最主要的步骤是根据ID找到刚才动态创建的ESTextMarker 实例。

  1. 点击单个设备查看详情,并进行设备控制操作

    function popInfo(target){
      
      const  {ID, x, y, name } = target
      const  popMarker1 = new esmap.ESPopMarker({
        mapCoord: {
          x,
          y,
          // 控制信息窗的空间高度
          height: 7, 
          // 设置弹框位于的楼层
          fnum: 1,
        },
        // 弹框的宽度
        width: 340,
        // 弹框的高度
        height: 200,
        // 弹框的内容
        content: `
          <div class="pop-info">
            <ul>
              <li><span>ID:</span>${ID}</li>
              <li><span>Name:</span>${name || '未知'}</li>
              <li><span>x:</span>${x}</li>
              <li><span>y:</span>${y}</li>
              <li> 
                <button onclick="lightOn(${ID})">开灯</button>
                <button onclick="lightOff(${ID})">关灯</button>
              </li>
            </ul>
          </div>
        `,
        closeCallBack: function () {
          console.log("信息窗关闭了!");
        },
        created: function () {},
      })
    
    }
    
  2. 控制单个设备的状态切换

    function lightOn(id){
      const marker = markerList.find(v=>v.ID == id)
      if(marker){
        marker.image = ICON_LIGHT_ON
      }
    }
    function lightOff(id){
      const marker = markerList.find(v=>v.ID == id)
      if(marker){
        marker.image = ICON_LIGHT_OFF
      }
    }
    
  3. 批量控制

    /**
     * 批量开灯
     */
    function lightOnAll(){
      markerList.forEach(marker=>{
        marker.image = ICON_LIGHT_ON
      })
    }
    /**
     * 批量关灯
     */
    function lightOffAll(){
      markerList.forEach(marker=>{
        marker.image = ICON_LIGHT_OFF
      })
    }
    
  4. 最终实现效果如下 Honeycam_2024-06-30_18-58-02.gif

4. 编辑标注

最后一个需求是支持编辑修改灯具位置,并提供保存功能。

  1. 这里的实现方式是提供添加和删除功能

    // 监听鼠标事件
    map.on('mapClickNode', (event)=>{
        if(event.nodeType == esmap.ESNodeType.TEXT_MARKER){
            if(isRemoveNode){
                removeMarker(event)
            }
        }
        mapCoord = event.hitCoord || null;
    })
    
    //为模型填充div添加点击事件
    container.onclick = function() {
      if (mapCoord) {
        console.log(JSON.stringify(mapCoord))
      }
      // 标注编辑
      if(isEditNode == true){
        createMarker({coord: mapCoord, id: new Date().getTime().toString()})
      }
    }
    
    // 添加点标注
    function createMarker(item){
      const building = map.getBuilding();
      const floorLayer = building.getFloor(1);
      const layer = floorLayer.getOrCreateLayerByName('dynamic', esmap.ESLayerType.TEXT_MARKER);
    
      const {coord,id} = item
      const marker = new esmap.ESTextMarker({
        id,
        x: coord.x,
        y: coord.y,
        height: 2.5, //离地高度
        image: ICON_LIGHT_OFF,
        imageSize: 64,
        scale: 1,
        fixedSize: true, //是否固定大小,默认为true,可选参数
      })
      layer.addMarker(marker)
      // 缓存标注
      markerList.push(marker)
    }
    
    // 删除点标注
    function removeMarker(target){
    
      const building = map.getBuilding();
      const floorLayer = building.getFloor(1);
    
      floorLayer.getLayersByNames('dynamic',(layer)=>{
        const index = markerList.findIndex(v=>v.ID == target.ID)
        layer.remove(markerList[index])
        markerList.splice(index, 1)
      })
    }
    
  2. 数据保存的功能实现很简单,只要把此前一直在维护的markerList保存下来就行了。

    // 前端保存标注数据
    function saveMarkerData(){
    
      const data =  markerList.map((marker, index)=>{
        const { x, y } = marker
        return {
          "id": new Date().getTime().toString() + index ,
          "name": "灯具",
          "coord": {x,y }
        }
      })
    
      console.log(data)
      localStorage.setItem('esMapLightData', JSON.stringify(data))
      console.info('数据缓存成功')
    
    }
    
  3. 我们看看最终效果 Honeycam_2024-06-30_21-02-42.gif

拓展开发

接到需求的几个小时候我这套演示录屏发给领导,果不其然得到了领导的肯定,另外趁着还有1.5天的时间,希望能够争取再优化一波。那么接下来都是附加分了,我研究了esMap的API文档和官方示例,又增加了以下几个小功能。

1.动态添加模型

在停车场场景中增加几个枪机摄像头的模型,用于检测进出人流统计,以便后续设施优化调整,这个需求也合理。

Honeycam_2024-06-30_21-16-34.gif

添加模型的方式与添加标注如出一辙,无非就是换了一种Marker类;这里要注意的坑就是自己导出的gltf不能直接通过代码加载到三维场景上,所有的模型必须上传,服务器加密绑定到三维场景上,这里也是个收费点 😒。附上官方的模型处理教程

//为模型填充div添加点击事件
container.onclick = function() {
  if (mapCoord) {
    console.log(JSON.stringify(mapCoord))
  }
  // 3D模型编辑
  if(isEditModel){
      addModel({coord: mapCoord, id: new Date().getTime().toString()})
  }
}
// 添加模型
function addModel(item){
  const building = map.getBuilding();
  const floorLayer = building.getFloor(1);
  var layer = floorLayer.getOrCreateLayerByName( "models",  esmap.ESLayerType.MODEL3D);
  const {coord,id} = item
  
  var im = new esmap.ES3DMarker({
    x: coord.x,
    y: coord.y,
    id,
    name: "tube",
    url: "/model/White-Gun-Camera.gltf", //需要与服务端绑定
    size: 5, //尺寸缩放
    angle: 260 *Math.random(), //朝向角度
    height: 2 //离地高度
  });
  layer.addMarker(im);
}

2.叠加热力图

有了几个摄像头定点检测统计人流量,接下来就可以做场地的人流热力图了。 Honeycam_2024-06-30_21-19-05.gif

热力图的实现原理就是给一个平面上分布的点添加不同的权重,根据权重大小和热点范围进行渲染,在这里只要esmap.ESHeatMap提供的热点设置方法就能达到效果,需要注意的地方是注意设置好热力图的目标楼层。

// 初始化热力图
const initHeatMap = function(){
    heatmapInstance = esmap.ESHeatMap.create(map,{
      bid: building.id,
      radius: 20, //热点半径
      opacity: 1, //热力图透明度
      mapOpacity: 0.2,  //设置三维场景楼层整体透明度
      backgroundColor:'#FFFFFF', //热力图背景颜色,默认白色
      max: 100, //热力点value的最大值
    });
}

// 添加随机热力图 
var addRandomHeatMap = function () {
  // 创建热力图对象
  if (!heatmapInstance){
    initHeatMap();
  }

  // 热力图归属于楼层1
  heatmapInstance.setFloorNum(1);
  // 清除已有的热点
  heatmapInstance.clearPoints();
  heatmapInstance.randomPoints(200);

  //热力图应用到指定的楼层
  var floorLayer = building.getFloor(1);
  floorLayer.applyHeatMap(heatmaz

3.路径规划和导航功能

停车场最大的刚需都有哪些?我认为其中少不得的一个刚需就是车主找车导航,特别是像我这种健忘和路痴车主,路径规划和导航功能尤为重要。esMap提供了一套成熟的路径自动规划功能供用户使用,实现这套功能也需要对场景进行一些编辑。

Honeycam_2024-06-30_21-26-28.gif

  1. 自动规划的路径不是凭空而生的,目前还没有这么智能,我们需要提前把场景里所有的路径都绘制出来,告诉导航模块哪些地方是可以人或车可以行走的,哪里是单行道哪里是双行道等等。打开“场景编辑-导航选项”,我们进行路径绘制 Untitled 4.png

  2. 注意将每条线段定义为”双向,其他-人行“,这样一来终点起点位置互换都没有问题。 Untitled 5.png

  3. 编写相关代码,路径规划这里在交互上有个设计,点击地图一次确定起点,第二次确定终点,第三次清空路径并重新确定起点。

    // 鼠标点击次数
    let clickCount = 0
    // 最后一次点击的坐标
    let lastCoord = null
    
    // 点击容器触发路径编辑,mapCoord为mapClickNode缓存的坐标
    container.onclick = function() {    
        if(isEditRoute){
            showRoute( mapCoord);
        }
    };
    
    // 显示规划路径
    function showRoute(coord){
      if (!navi) return;
      if (coord != null) {
        //第三次点击清除路径,重现设置起点起点
        if (clickCount == 2) {
          navi.clearAll();
          clickCount = 0;
          lastCoord = null;
        }
    
        //第一次点击添加起点
        if (clickCount == 0) {
          lastCoord = coord;
          navi.setStartPoint({
            x: coord.x,
            y: coord.y,
            fnum: 1,
            url: "image/start.png",
            height: 1,
            size: 64,
          });
        } else if (clickCount == 1) {
          //添加终点并画路线
          //判断起点和终点是否相同
          if (lastCoord.x == coord.x) {
            alert("起点和终点不能相同!,请重新选点");
            return;
          }
          navi.setEndPoint({
            x: coord.x,
            y: coord.y,
            fnum: 1,
            url: "image/end.png",
            height: 1,
            size: 64,
          });
    
          //画导航线
          navi.getRouteResult({
            drawRoute: true,
          })
        }
        clickCount++;
      }
    }
    
    // 开始导航
    function startNavi() {
      if (navi.isSimulating) {
        // 正在导航中
        return;
      }
    
      navi.followAngle = true;
      navi.followPosition = true;
      navi.scaleAnimate = false;
      navi.simulate();
    }
    
    // 重置导航
    function resetNav() {
      navi.stop();
      navi.clearAll();
    }
    

写在最后

以上就是我这次搞定需求所有用到的内容了,代码下载链接放到这里供大家下载交流。这些内容说难不难,我们当然都可以使用three.js+其他webGL库实现,问题是使用three.js的话可能要从砖块开始垒起了 😂 ,估计没有一周时间下不来,但是使用esMap的话我可能2-4个小时就搞定了。

esMap作为一个SaSS编辑平台,最大的优点是快速便捷搭建,最大的缺点就是有收费项目,比如免费场景数量有限,模型库收费,上传使用自定义模型也要收费,不过这些也都可以理解啦,毕竟没有谁能永远活在真空中不花钱吃饭。有时候换个角度想一想,其实我们不需要那么多技术情节,如果在某些付费平台上花费一点点钱就能够解决十万火急的问题,或者就能替代十天半个月的工作量,出了bug只要反馈给开发团队不需要自己处理,相当于雇佣了1个团队的廉价劳动力了,何乐而不为 😬。

相关链接

esMap代码在线演示

esMap的API文档

工程代码