当前位置: 首页 > article >正文

【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 Viewer
    this.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项目,或者遇到类似的技术挑战,欢迎与我分享你的经验与困惑。


http://www.kler.cn/a/508237.html

相关文章:

  • 论文笔记-arXiv2025-A survey about Cold Start Recommendation
  • Android 12.0 息屏休眠后立即启动屏保功能实现
  • 2019-Android-高级面试题总结-从java语言到AIDL使用与原理
  • 浅谈云计算20 | OpenStack管理模块(下)
  • 51单片机——DS18B20温度传感器
  • 网络科技有限公司网络设计
  • Spring Boot--@PathVariable、@RequestParam、@RequestBody
  • 如何使用 Go语言操作亚马逊 S3 对象云存储
  • 【Cesium入门教程】第一课:Cesium简介与快速入门详细教程
  • 机器学习——集成学习、线性模型、支持向量机、K近邻、决策树、朴素贝叶斯、虚拟分类器分析电动车数据集Python完整代码
  • boss直聘 __zp_stoken__ 分析
  • 【Unity3D】远处的物体会闪烁问题(深度冲突) Reversed-Z
  • 【Go】Go Gorm 详解
  • Ardupilot开源无人机之Geek SDK进展2024
  • ThinkPHP 8的一对多关联
  • 花样贪吃蛇
  • (即插即用模块-Attention部分) 四十四、(ICIP 2022) HWA 半小波注意力
  • DevUI 2024 年度运营报告:开源生态的成长足迹与未来蓝图
  • vue v-if和key值的注意的地方
  • 跨站请求伪造(CSRF)介绍
  • 多监控m3u8视频流,怎么获取每个监控的封面图(纯前端)
  • redis做为缓存,mysql的数据如何与redis进行同步呢?
  • c#函数式编程
  • 微信小程序码生成
  • 力扣解题汇总(简单)_JAVA
  • ZooKeeper 常见问题与核心机制解析