欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 文旅 > 明星 > 【GIS系列】打造3维GIS数字孪生效果系统:Cesium+Mapbox+SpringBoot完美实现解析

【GIS系列】打造3维GIS数字孪生效果系统:Cesium+Mapbox+SpringBoot完美实现解析

2025/1/19 5:13:30 来源:https://blog.csdn.net/c18213590220/article/details/144957711  浏览:    关键词:【GIS系列】打造3维GIS数字孪生效果系统:Cesium+Mapbox+SpringBoot完美实现解析

三年前,我开发了一个基于 Cesium + Mapbox + Spring Boot 的3维GIS系统项目,整体实现效果非常不错。遗憾的是,由于非技术层面的原因,该项目在即将交付时未能如期上线。尽管如此,这个项目曾经还是成为了我面试中的重要亮点,并帮助我获得了许多工作机会。最近,与我一同开发该项目的前端同事联系我,表示希望获取源码用于面试演示(当时整个系统仅由我们两人开发,他负责纯前端界面,我负责全部后端逻辑和 GIS 前端代码的编写)。经过深思熟虑,我决定将这个系统开发过程中的技术经验进行总结与分享,希望为有类似需求的读者提供一些参考与启发。

目录

1. 前言

2. 一些踩坑和吐槽(不感兴趣的可以直接跳过)

3. 实现效果演示

4. 核心技术讲解

4.1. 场景构建

4.2. 表结构及核心代码讲解

4.2.1. 表结构

4.2.2. 场景加载

4.2.2.1. 初始场景加载

4.2.2.2. 加载所有建筑

4.2.2.3. 建筑与窗户分离场景加载(单体化)

4.2.3. 住户信息绑定

4.2.3.1. 绑定初始化

4.2.3.2. 绑定窗户

5. 结语


1. 前言

在数字化转型和智慧城市建设的浪潮中,数字孪生技术逐渐成为提升数据可视化与精准监管能力的重要工具,在各行业的应用前景日益广泛。本文系统初衷是设计一套数据管理系统,以实现对社区特殊人群的精细化管理。我们为此构建了一套基于 CesiumMapbox 和 SpringBoot 的数字孪生效果系统,能够通过直观的方式对特殊人群的居住情况进行有效监管。

2. 一些踩坑和吐槽(不感兴趣的可以直接跳过)

环境的坑:

这是三年前的项目了,光是把它跑起来就让我费了不少力气。首先是 Vue 环境的问题:三年前,我的 Node.js 版本在当时算是“高配”,但后来我渐渐不写前端了,前端技术栈也开始“躺平”。去年我写了一个工作流系统,用的 Node.js 是 10 的版本,Vue 也还是 Vue2,老得都快发灰了。而这个3维GIS系统用的是 Vue3,导致前端依赖始终拉不下来,急得我头都大了。

好在,经过一番折腾,我发现了解决之道:nvm!(https://github.com/nvm-sh/nvm)真是个好东西,堪比穿越时空的工具箱,直接把 Node.js 环境切换到项目需要的版本,问题就迎刃而解了。

Mapbox的坑:

还有就是Mapbox底图的问题。如下图所示,建筑下面的底图是Mapbox底图。

把项目跑起来后,我发现 Mapbox 的 token 竟然过期了。行吧,登录 Mapbox 官网去获取新的 token。然而,让我懵圈的是,现在居然需要绑定个人信息才能获取(三年前完全没有这种操作啊!)。我实在不想绑这些东西,只好辗转用了些“特殊手段”搞了一个 Mapbox 账号。

不过,这也没能让我逃过下一个坑——之前的系统底图没了!没办法,只好换了一个现成的底图将就用。好在最终项目总算跑起来了,虽然和当初的效果略有不同,但对我来说这些都不重要,关键是要把实现系统的思路分享给大家。

3. 实现效果演示

这个系统的开发初衷是客户要求实现一个具有数字孪生效果的三维GIS系统,可以查看社区下特殊人员的汇总信息和详细信息(通过点击建筑窗户来查看对应户主的详细信息)。

整体效果:

近距离效果:

查看单栋建筑人员信息:

点击特殊人员种类后对应的窗户联动: 

点击窗户查看对应户主详情:

楼栋信息管理:

绑定窗户与对应房号:

4. 核心技术讲解

4.1. 场景构建

整个场景是基于真实的 84地理坐标系 构建的,房屋模型则基于客户提供的房屋 CAD图 制作而成。当时,我们的建模小姐姐凭借强大的耐心和技术,按照 CAD 图 1:1 完美复刻了建筑模型。然而,第一版模型导入 Cesium 后,直接把界面“卡成了PPT”。原因很简单——模型太精细了。

为了解决这个问题,我们对模型进行了抽稀处理,但结果还是不理想,依然卡顿得让人抓狂。客户要求每个窗户都必须单体化,也就是说每扇窗户都是一个独立的要素(这就需要把建筑与窗户进行分离,如下图)。随着建筑数量的增加,前端的加载压力成倍增长,直接把界面“按在地上摩擦”。

最终,为了提升性能,我们不得不进行业务逻辑上的优化。初始场景中,加载的是所有建筑的简易模型(Box加贴图),只有在点击某栋建筑时,才会加载这栋建筑的精细模型和窗户的分离模型。这种按需加载的策略有效缓解了性能问题,同时也让客户的需求得到了妥善实现。虽然过程有点折腾,但也算是用汗水换来了经验。

4.2. 表结构及核心代码讲解

4.2.1. 表结构

整个系统涉及到的表一共有5张,分别为存储社区信息的zp_community、存储楼栋信息的zp_build、存储房屋信息的zp_room、存储窗户信息的zp_window,以及存储户主信息的zp_household

以下是每张表的具体设计与作用:

1. zp_community 社区表

  • 功能:存储社区的基础信息。
  • 字段
    • zp_community_id:社区 ID,主键,用于唯一标识每个社区。
    • zp_community_name:社区名称。
  • 关系:每个社区下包含多个楼栋,与 zp_build 表通过 zp_community_id 建立一对多关系。

2. zp_build 楼栋表

  • 功能:存储楼栋的详细信息。
  • 字段
    • zp_build_id:楼栋 ID,主键,用于唯一标识每个楼栋。
    • zp_build_name:楼栋名称,例如“A5_10”。
    • zp_community_id:社区 ID,外键,关联到 zp_community 表。
    • zp_build_info:楼栋附加信息,例如描述或备注。
  • 关系:与 zp_community 表建立多对一关系,同时与 zp_room 表通过 zp_build_id 建立一对多关系。

3. zp_room 房屋表

  • 功能:存储房屋的具体信息。
  • 字段
    • zp_room_id:房屋 ID,主键,用于唯一标识每间房屋。
    • zp_build_id:楼栋 ID,外键,关联到 zp_build 表。
    • zp_unit_name:单元名称,用于描述房屋所属单元。
    • zp_room_name:房屋名称,例如“101”。
    • isbind:房屋绑定状态,标识房屋是否与住户信息绑定。
  • 关系
    • zp_build 表建立多对一关系。
    • zp_window 表通过 zp_room_id 建立一对多关系。
    • zp_household 表通过 zp_room_id 建立一对多关系。

4. zp_window 窗户表

  • 功能:存储窗户的单体化信息。
  • 字段
    • zp_window_id:窗户 ID,主键,用于唯一标识每个窗户。
    • zp_room_id:房屋 ID,外键,关联到 zp_room 表。
    • zp_window_name:窗户名称。
  • 关系:与 zp_room 表建立多对一关系,一个房屋可以包含多个窗户。

5. zp_household 户主表

  • 功能:存储住户信息。
  • 字段
    • zp_household_id:住户 ID,主键,用于唯一标识每位住户。
    • zp_room_id:房屋 ID,外键,关联到 zp_room 表。
    • zp_household_name:住户姓名。
    • zp_household_identity:住户身份,例如“户主”、“租客”、“子女”等。
    • zp_household_phone:联系电话。
    • 其他字段:性别、年龄、状态(如危险、正常)等描述住户的其他信息。
  • 关系:与 zp_room 表建立多对一关系,一个房屋可对应多个住户。

通过主外键关联,整个数据表的结构从社区到楼栋再到房屋、窗户、住户,形成了一套完整的层次化管理体系:

  • 社区 -> 楼栋 -> 房屋 -> 窗户:实现从宏观到微观的空间数据管理。
  •  窗户 ->住户:支持窗户与业务数据(住户)的绑定。
4.2.2. 场景加载
4.2.2.1. 初始场景加载

前端代码:

initScene() {// 初始化 Cesium Viewerthis.viewer = new Cesium.Viewer("cesiumContainer", {geocoder: false,        // 位置查找工具homeButton: false,      // 复位按钮sceneModePicker: false, // 模式切换baseLayerPicker: false, // 图层选择navigationHelpButton: false,animation: false,       // 速度控制timeline: false,        // 时间轴fullscreenButton: false,// 全屏infoBox: false,selectionIndicator: false});// 添加 Mapbox 底图var layer = new Cesium.MapboxStyleImageryProvider({/*...*/});this.viewer.imageryLayers.addImageryProvider(layer);
}

mapbox底图添加代码:

  

4.2.2.2. 加载所有建筑

前端代码:

加载所有房屋:

addAllHouse() {// 遍历建筑物数据,初始化每个建筑for (let i = 0; i < this.buildingName.length; i++) {this.initHouse("/houseUrl" + this.buildingData[this.buildingName[i]].name + ".gltf",Cesium.Cartesian3.fromDegrees(/*经纬度位置*/),this.models,this.buildingName[i]);}// 添加到场景this.addModel(this.models, this.entities);
}

这个函数的功能是批量加载所有建筑物模型到场景中。其流程为:

  • 遍历 buildingName 数组,获取每个建筑物的配置信息
  • 调用 initHouse() 函数,根据建筑物数据创建模型对象,包含:
  • 模型路径(gltf文件)
  • 位置信息(经纬度坐标)
  • 模型数组引用
  • 建筑物ID
  • 将创建的模型对象存入 models 数组
  • 最后调用 addModel() 函数,将所有模型一次性添加到 Cesium 场景的 entities 中进行渲染

这是场景初始化时的重要函数,负责将所有建筑物的 3D 模型加载并显示到地图上。整个流程是:配置数据 -> 模型对象 -> 场景渲染的转换过程。

添加 3D 模型到 Cesium 场景函数:

addModel(models, entities) {if (models && models.length > 0) {for (let i = 0; i < models.length; i++) {let heading = Cesium.Math.toRadians(65);var pitch = 0;var roll = 0;var hpr = new Cesium.HeadingPitchRoll(heading, pitch, roll);var orientation = Cesium.Transforms.headingPitchRollQuaternion(models[i].position,hpr);this.locatEntity = entities.add({name: models[i].name,id: models[i].id,position: models[i].position,orientation: orientation,model: {color: null,silhouetteColor: null,silhouetteAlpha: 0,silhouetteSize: 0,show: true,uri: models[i].url,scale: 1.0, // 缩放比例minimumPixelSize: 1, // 最小像素大小maximumScale: 1, // 模型的最大比例尺大小。 minimumPixelSize的上限incrementallyLoadTextures: true, // 加载模型后纹理是否可以继续流入runAnimations: true, // 是否应启动模型中指定的glTF动画clampAnimations: true, // 指定glTF动画是否应在没有关键帧的持续时间内保持最后一个姿势// 指定模型是否投射或接收来自光源的阴影 type:ShadowMode// DISABLED 对象不投射或接收阴影;ENABLED 对象投射并接收阴影;CAST_ONLY  对象仅投射阴影;RECEIVE_ONLY  对象仅接收阴影shadows: Cesium.ShadowMode.ENABLED,heightReference: Cesium.HeightReference.NONE,},});}}}
4.2.2.3. 建筑与窗户分离场景加载(单体化)

前端代码:

loadClickHouse(buildingId) {// 清除现有实体this.viewer.entities.removeAll();// 加载分离的窗户模型axios.get("/redisWindow/windowUrls/" + buildingId).then((res) => {// 加载建筑主体this.initHouse(/*...*/);// 加载每个窗户const urlArr = res.data[buildingId];for (let i = 0; i < urlArr.length; i++) {this.initClickWindow(/*...*/);}});
}

这个函数实现了点击建筑物后的分户展示功能。其流程为:

  • 开启加载状态,设置 2 秒的加载动画
  • 清空场景中现有的实体(viewer.entities.removeAll())和模型数组(models = [])
  • 通过 axios 请求 /zz/redisWindow/windowUrls/{buildingId} 获取该建筑的窗户数据
  • 请求成功后:
  • 先加载建筑主体模型(initHouse())
  • 遍历窗户数据数组,逐个加载窗户模型(initClickWindow())
  • 将所有模型添加到场景(addModel())
  • 添加建筑标签和按钮标签
  • 最终实现建筑物从整体模型向可交互的分户模型的转换

这是实现建筑物点击交互的核心函数,将整体建筑拆分为可独立操作的窗户单元,为后续的窗户选择和信息绑定提供基础。

后端代码:

在后端我把窗户模型丢到了redis中进行存储,对应的接口为:

   public Map<String,Set> getWindowUrls(String buildBH) {Map<String,Set>windowUrls=new HashMap<>();Set<Object> windowBH=redisUtils.sGet(buildBH);windowUrls.put(buildBH,windowBH);return windowUrls;}

redis中存的是建筑下对用的窗户编号以及窗户编号对应的gltf模型信息:

 

A5_10为建筑编号, A5_10_w_1为窗户编号

4.2.3. 住户信息绑定
4.2.3.1. 绑定初始化

前端代码:

bindWindow(viewer, entities, bindWindows) {// 移除其他事件处理器this.removeAllHandler();// 创建绑定事件处理器this.handlerBind = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);// 处理 Ctrl + 点击事件this.handlerBind.setInputAction(function (movement) {if (this.isInit == 2) {  // 绑定模式var pick = viewer.scene.pick(movement.position);if (pick && pick.id && pick.id._id.indexOf("_w_") != -1) {// 处理窗户选择windowsList.push(pick.id._id);// 高亮显示this.lightwindow(entities, windowsList, 0);}}},Cesium.ScreenSpaceEventType.LEFT_CLICK,Cesium.KeyboardEventModifier.CTRL);
}

项目使用 isInit 变量管理不同的交互状态:

  • isInit = 0: 初始化状态,可点击建筑
  • isInit = 1: 建筑分解状态,可查看窗户信息
  • isInit = 2: 绑定模式,可进行窗户绑定

4.2.3.2. 绑定窗户

前端代码:

// 获取窗户信息
this.$axios.getHouseholdInfo({ windowId: windowId }).then(res => {if (res.code == 200) {// 处理返回数据}});// 提交绑定
axios.post("/room/bindWindow", {roomId: val.name,windowId: [...this.windowsData]
})

这段代码实现了窗户信息的查询和绑定功能。其流程为:

  • 查询窗户信息:
  • 通过 getHouseholdInfo 接口,传入 windowId 参数
  • 获取该窗户关联的住户信息
  • 如果返回 code 为 200,则处理返回的住户数据
  • 绑定窗户:
  • 通过 /room/bindWindow 接口提交绑定信息
  • 传入房间ID (roomId) 和窗户ID数组 (windowId)
  • 将选中的窗户与指定房间建立关联关系

后端代码:

    public Result bindWindow(BindReq bindReq) {String roomId=bindReq.getRoomId();List<String>windowsId=bindReq.getWindowId();try {for (int i = 0; i < windowsId.size(); i++) {roomMapper.updateWindow(roomId,windowsId.get(i));}} catch (Exception e) {e.printStackTrace();return Result.error("绑定失败");}return Result.ok("绑定成功");}

5. 结语

项目历时一个半月,最终呈现了本文中所展示的效果。希望通过这篇文章的分享,能够为有类似需求的开发者提供一些实用的启示与帮助。如果你也在进行类似的3维GIS项目,或者遇到类似的技术挑战,欢迎与我分享你的经验与困惑。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com