数字孪生实战:从零搭建室内导航系统

数字孪生实战:从零搭建室内导航系统

最近做了一个室内导航数字孪生项目,涉及多模型分层加载、MQTT实时定位、历史轨迹回放、自定义围栏告警等。本文记录核心实现和踩坑点,代码已脱敏。

一、项目背景

某商业综合体需要做一套室内导航系统,支持:

  • 3D楼层可视化(单层/外部/内部模型分层加载)
  • 实时定位导航(UWB + MQTT)
  • 历史轨迹查询回放
  • 自定义围栏告警

技术栈:Cesium + Vue3 + Node.js + MQTT + IndexedDB

二、整体架构

┌─────────────────────────────────────────────────────────────────┐
│                        前端展示层                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 3D场景渲染    │  │ 导航控制层    │  │ 围栏告警层   │          │
│  │ (Cesium)     │  │              │  │              │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        数据服务层                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 模型缓存服务  │  │ 定位服务      │  │ 轨迹服务     │          │
│  │ (IndexedDB)  │  │ (MQTT)       │  │              │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        数据采集层                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ UWB基站       │  │ MQTT Broker  │  │ 历史数据库    │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘

三、多模型分层加载与缓存

3.1 模型分类

建筑模型按层级拆分,按需加载:

模型类型 格式 用途 大小控制
外部模型 3DTiles 建筑外观、园区整体 单栋 < 20MB
单层模型 GLB 每层楼板、墙体、商铺 单层 < 5MB
内部模型 GLB 店铺内部、设备详情 按需加载

3.2 IndexedDB缓存管理

模型文件缓存到IndexedDB,避免重复下载,设置1GB内存上限,超出后按LRU淘汰:

// 模型缓存管理器
class ModelCacheManager {
  constructor(maxSizeGB = 1) {
    this.dbName = 'model_cache';
    this.storeName = 'glb_models';
    this.maxSize = maxSizeGB * 1024 * 1024 * 1024; // 1GB
    this.db = null;
  }

  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);
      
      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          const store = db.createObjectStore(this.storeName, { keyPath: 'url' });
          store.createIndex('lastAccess', 'lastAccess');
          store.createIndex('size', 'size');
        }
      };
      
      request.onsuccess = (e) => {
        this.db = e.target.result;
        resolve();
      };
      request.onerror = (e) => reject(e);
    });
  }

  // 获取缓存的模型
  async get(url) {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    
    return new Promise((resolve, reject) => {
      const request = store.get(url);
      request.onsuccess = () => {
        const result = request.result;
        if (result) {
          // 更新访问时间
          result.lastAccess = Date.now();
          store.put(result);
          resolve(result.data);
        } else {
          resolve(null);
        }
      };
      request.onerror = () => reject(request.error);
    });
  }

  // 存入缓存
  async put(url, data) {
    const size = data.byteLength || data.size || 0;
    
    // 检查空间,不够则淘汰
    await this.evictIfNeeded(size);
    
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    
    return new Promise((resolve, reject) => {
      const request = store.put({
        url,
        data,
        size,
        lastAccess: Date.now(),
        cachedAt: Date.now()
      });
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  // LRU淘汰:移除最久未访问的缓存,直到腾出足够空间
  async evictIfNeeded(neededSize) {
    const currentSize = await this.getCurrentSize();
    
    if (currentSize + neededSize <= this.maxSize) {
      return; // 空间足够
    }
    
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    const index = store.index('lastAccess');
    
    // 按访问时间升序遍历,移除最旧的
    let freed = 0;
    const toFree = (currentSize + neededSize) - this.maxSize;
    
    return new Promise((resolve, reject) => {
      const request = index.openCursor();
      request.onsuccess = (e) => {
        const cursor = e.target.result;
        if (cursor && freed < toFree) {
          freed += cursor.value.size;
          cursor.delete();
          cursor.continue();
        } else {
          resolve();
        }
      };
      request.onerror = () => reject(request.error);
    });
  }

  // 获取当前缓存总大小
  async getCurrentSize() {
    const tx = this.db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    
    return new Promise((resolve, reject) => {
      let totalSize = 0;
      const request = store.openCursor();
      request.onsuccess = (e) => {
        const cursor = e.target.result;
        if (cursor) {
          totalSize += cursor.value.size || 0;
          cursor.continue();
        } else {
          resolve(totalSize);
        }
      };
      request.onerror = () => reject(request.error);
    });
  }

  // 清空缓存
  async clear() {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    return new Promise((resolve, reject) => {
      const request = store.clear();
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
}

3.3 模型加载器

封装统一的加载逻辑,支持缓存:

class ModelLoader {
  constructor(viewer) {
    this.viewer = viewer;
    this.cacheManager = new ModelCacheManager(1); // 1GB限制
    this.loadedModels = new Map(); // url -> primitive
    this.init();
  }

  async init() {
    await this.cacheManager.init();
  }

  // 加载3DTiles(外部模型)
  async load3DTiles(url, options = {}) {
    try {
      const tileset = await Cesium.Cesium3DTileset.fromUrl(url, {
        maximumCacheOverflowBytes: 0, // 禁用Cesium自带缓存,用我们的
        ...options
      });
      
      this.viewer.scene.primitives.add(tileset);
      this.loadedModels.set(url, { type: '3dtiles', primitive: tileset });
      
      return tileset;
    } catch (err) {
      console.error(`加载3DTiles失败: ${url}`, err);
      throw err;
    }
  }

  // 加载GLB模型(单层/内部模型),支持IndexedDB缓存
  async loadGLB(url, position, options = {}) {
    // 先查缓存
    let glbData = await this.cacheManager.get(url);
    
    if (!glbData) {
      console.log(`缓存未命中,下载: ${url}`);
      glbData = await this.fetchArrayBuffer(url);
      await this.cacheManager.put(url, glbData);
      console.log(`已缓存: ${url}`);
    } else {
      console.log(`缓存命中: ${url}`);
    }

    // 从ArrayBuffer创建模型
    const blob = new Blob([glbData], { type: 'model/gltf-binary' });
    const blobUrl = URL.createObjectURL(blob);
    
    const model = await Cesium.Model.fromGltfAsync({
      url: blobUrl,
      modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(
        Cesium.Cartesian3.fromDegrees(position.lng, position.lat, position.alt || 0)
      ),
      scale: options.scale || 1.0,
      ...options
    });
    
    this.viewer.scene.primitives.add(model);
    this.loadedModels.set(url, { type: 'glb', primitive: model, blobUrl });
    
    return model;
  }

  // 卸载模型
  unload(url) {
    const entry = this.loadedModels.get(url);
    if (!entry) return;
    
    this.viewer.scene.primitives.remove(entry.primitive);
    
    if (entry.blobUrl) {
      URL.revokeObjectURL(entry.blobUrl);
    }
    
    this.loadedModels.delete(url);
  }

  // 获取缓存状态
  async getCacheStatus() {
    const currentSize = await this.cacheManager.getCurrentSize();
    return {
      used: currentSize,
      max: this.cacheManager.maxSize,
      usedMB: Math.round(currentSize / 1024 / 1024),
      maxMB: Math.round(this.cacheManager.maxSize / 1024 / 1024),
      usage: Math.round(currentSize / this.cacheManager.maxSize * 100)
    };
  }

  async fetchArrayBuffer(url) {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.arrayBuffer();
  }
}

3.4 楼层管理器

class FloorManager {
  constructor(viewer, modelLoader) {
    this.viewer = viewer;
    this.loader = modelLoader;
    this.floors = new Map(); // floorId -> { models, config }
    this.currentFloor = null;
  }

  // 注册楼层配置
  registerFloor(floorId, config) {
    this.floors.set(floorId, {
      config,
      models: new Map(),
      loaded: false
    });
  }

  // 加载楼层(单层模型 + 内部模型)
  async loadFloor(floorId) {
    const floor = this.floors.get(floorId);
    if (!floor || floor.loaded) return;

    const { config } = floor;

    // 加载楼层主体模型
    const mainModel = await this.loader.loadGLB(
      config.mainModelUrl,
      config.position,
      { scale: config.scale || 1.0 }
    );
    floor.models.set('main', mainModel);

    // 预加载该层的内部模型(店铺等)
    if (config.interiorModels) {
      for (const item of config.interiorModels) {
        if (item.preload) {
          const model = await this.loader.loadGLB(item.url, item.position);
          floor.models.set(item.id, model);
        }
      }
    }

    floor.loaded = true;
  }

  // 切换楼层
  async switchFloor(floorId) {
    // 隐藏当前楼层
    if (this.currentFloor) {
      const current = this.floors.get(this.currentFloor);
      if (current) {
        for (const model of current.models.values()) {
          model.show = false;
        }
      }
    }

    // 加载并显示目标楼层
    await this.loadFloor(floorId);
    const target = this.floors.get(floorId);
    if (target) {
      for (const model of target.models.values()) {
        model.show = true;
      }
      this.currentFloor = floorId;
      this.flyToFloor(floorId);
    }
  }

  flyToFloor(floorId) {
    const floor = this.floors.get(floorId);
    if (!floor) return;

    const { position } = floor.config;
    this.viewer.camera.flyTo({
      destination: Cesium.Cartesian3.fromDegrees(
        position.lng, position.lat, (position.alt || 0) + 50
      ),
      orientation: {
        heading: 0,
        pitch: Cesium.Math.toRadians(-45),
        roll: 0
      }
    });
  }
}

四、MQTT实时定位

4.1 UWB数据接入

通过MQTT接收UWB基站的定位数据,格式:

{
  "device_id": "uwb_tag_001",
  "type": "person",
  "floor": "F3",
  "position": { "x": 125.5, "y": 67.2, "z": 3.0 },
  "timestamp": 1689234567890,
  "battery": 85
}

4.2 MQTT客户端

class UWBLocationClient {
  constructor(options) {
    this.brokerUrl = options.brokerUrl; // ws://mqtt.example.com:8083/mqtt
    this.topic = options.topic || 'building/+/uwb/#';
    this.client = null;
    this.handlers = new Map(); // eventType -> callback[]
  }

  connect() {
    this.client = mqtt.connect(this.brokerUrl, {
      clientId: `nav_client_${Date.now()}`,
      clean: true,
      reconnectPeriod: 3000
    });

    this.client.on('connect', () => {
      console.log('MQTT已连接');
      this.client.subscribe(this.topic, { qos: 1 });
    });

    this.client.on('message', (topic, payload) => {
      try {
        const data = JSON.parse(payload.toString());
        this.handleMessage(topic, data);
      } catch (err) {
        console.error('MQTT消息解析失败:', err);
      }
    });

    this.client.on('error', (err) => {
      console.error('MQTT错误:', err);
    });
  }

  handleMessage(topic, data) {
    const topicParts = topic.split('/');
    const building = topicParts[1];
    const type = topicParts[3]; // uwb

    // 触发位置更新事件
    this.emit('location_update', {
      ...data,
      building
    });
  }

  on(event, handler) {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event).push(handler);
  }

  emit(event, data) {
    const handlers = this.handlers.get(event) || [];
    handlers.forEach(h => h(data));
  }

  disconnect() {
    if (this.client) {
      this.client.end();
    }
  }
}

4.3 实时位置渲染

在Cesium中渲染人员/设备的实时位置:

class RealtimePositionRenderer {
  constructor(viewer, mqttClient) {
    this.viewer = viewer;
    this.mqttClient = mqttClient;
    this.entities = new Map(); // device_id -> entity
    this.trailLength = 50; // 轨迹点数量

    // 监听位置更新
    this.mqttClient.on('location_update', (data) => {
      this.updatePosition(data);
    });
  }

  updatePosition(data) {
    const { device_id, position, floor, type, battery } = data;
    
    let entity = this.entities.get(device_id);
    
    if (!entity) {
      // 创建新实体
      entity = this.createEntity(device_id, type);
      this.entities.set(device_id, entity);
    }

    // 更新位置
    const cartesian = Cesium.Cartesian3.fromDegrees(
      position.x, position.y, position.z
    );
    
    entity.position = cartesian;
    
    // 更新附加信息
    entity.description = `
      <table>
        <tr><td>设备ID</td><td>${device_id}</td></tr>
        <tr><td>类型</td><td>${type}</td></tr>
        <tr><td>楼层</td><td>${floor}</td></tr>
        <tr><td>电量</td><td>${battery}%</td></tr>
        <tr><td>更新时间</td><td>${new Date(data.timestamp).toLocaleString()}</td></tr>
      </table>
    `;

    // 电量低告警样式
    if (battery < 20) {
      entity.billboard.image = '/assets/icons/tag-low-battery.png';
    }
  }

  createEntity(deviceId, type) {
    const icons = {
      person: '/assets/icons/person.png',
      device: '/assets/icons/device.png',
      vehicle: '/assets/icons/vehicle.png'
    };

    return this.viewer.entities.add({
      id: `uwb_${deviceId}`,
      billboard: {
        image: icons[type] || icons.person,
        width: 32,
        height: 32,
        verticalOrigin: Cesium.VerticalOrigin.BOTTOM
      },
      label: {
        text: deviceId,
        font: '12px sans-serif',
        style: Cesium.LabelStyle.FILL_AND_OUTLINE,
        outlineWidth: 2,
        verticalOrigin: Cesium.VerticalOrigin.TOP,
        pixelOffset: new Cesium.Cartesian2(0, 20)
      }
    });
  }

  // 清除指定实体
  removeEntity(deviceId) {
    const entity = this.entities.get(deviceId);
    if (entity) {
      this.viewer.entities.remove(entity);
      this.entities.delete(deviceId);
    }
  }

  // 清除所有
  clearAll() {
    for (const [id, entity] of this.entities) {
      this.viewer.entities.remove(entity);
    }
    this.entities.clear();
  }
}

五、历史轨迹查询

5.1 轨迹数据查询

class TrajectoryService {
  constructor(apiBaseUrl) {
    this.apiBaseUrl = apiBaseUrl;
  }

  // 查询历史轨迹
  async queryTrajectory(deviceId, startTime, endTime) {
    const params = new URLSearchParams({
      device_id: deviceId,
      start: startTime.toISOString(),
      end: endTime.toISOString(),
      interval: '5s' // 采样间隔
    });

    const response = await fetch(
      `${this.apiBaseUrl}/trajectory?${params}`
    );
    
    if (!response.ok) throw new Error(`查询失败: ${response.status}`);
    
    const data = await response.json();
    return data.points; // [{x, y, z, floor, timestamp}, ...]
  }

  // 查询某时间段内所有人员的轨迹
  async queryAllTrajectories(startTime, endTime, floor = null) {
    const params = new URLSearchParams({
      start: startTime.toISOString(),
      end: endTime.toISOString()
    });
    if (floor) params.set('floor', floor);

    const response = await fetch(
      `${this.apiBaseUrl}/trajectory/all?${params}`
    );
    
    return response.json(); // { device_id: [points], ... }
  }
}

5.2 轨迹回放渲染

class TrajectoryRenderer {
  constructor(viewer) {
    this.viewer = viewer;
    this.trajectoryEntities = new Map(); // deviceId -> entity[]
  }

  // 绘制轨迹线
  drawTrajectory(deviceId, points, options = {}) {
    this.clearTrajectory(deviceId);

    if (points.length < 2) return;

    const positions = points.map(p => 
      Cesium.Cartesian3.fromDegrees(p.x, p.y, p.z)
    );

    // 轨迹线
    const lineEntity = this.viewer.entities.add({
      polyline: {
        positions,
        width: options.width || 3,
        material: new Cesium.PolylineGlowMaterialProperty({
          glowPower: 0.2,
          color: options.color || Cesium.Color.CYAN
        })
      }
    });

    // 起点标记
    const startEntity = this.viewer.entities.add({
      position: positions[0],
      point: {
        pixelSize: 10,
        color: Cesium.Color.GREEN
      },
      label: {
        text: '起点',
        font: '14px sans-serif',
        pixelOffset: new Cesium.Cartesian2(0, -20)
      }
    });

    // 终点标记
    const endEntity = this.viewer.entities.add({
      position: positions[positions.length - 1],
      point: {
        pixelSize: 10,
        color: Cesium.Color.RED
      },
      label: {
        text: '终点',
        font: '14px sans-serif',
        pixelOffset: new Cesium.Cartesian2(0, -20)
      }
    });

    this.trajectoryEntities.set(deviceId, [lineEntity, startEntity, endEntity]);
  }

  // 轨迹动画回放
  async playTrajectory(deviceId, points, speed = 1) {
    this.clearTrajectory(deviceId);

    if (points.length < 2) return;

    // 创建移动点
    const movingEntity = this.viewer.entities.add({
      position: Cesium.Cartesian3.fromDegrees(points[0].x, points[0].y, points[0].z),
      point: {
        pixelSize: 12,
        color: Cesium.Color.YELLOW
      },
      path: {
        leadTime: 0,
        trailTime: 60,
        width: 2,
        material: new Cesium.ColorMaterialProperty(
          Cesium.Color.YELLOW.withAlpha(0.5)
        )
      }
    });

    this.trajectoryEntities.set(deviceId, [movingEntity]);

    // 动画播放
    for (let i = 0; i < points.length; i++) {
      const p = points[i];
      const position = Cesium.Cartesian3.fromDegrees(p.x, p.y, p.z);
      movingEntity.position = position;

      // 控制播放速度
      if (i < points.length - 1) {
        const delay = (points[i + 1].timestamp - p.timestamp) / speed;
        await new Promise(resolve => setTimeout(resolve, Math.min(delay, 1000)));
      }
    }
  }

  clearTrajectory(deviceId) {
    const entities = this.trajectoryEntities.get(deviceId) || [];
    entities.forEach(e => this.viewer.entities.remove(e));
    this.trajectoryEntities.delete(deviceId);
  }

  clearAll() {
    for (const [id] of this.trajectoryEntities) {
      this.clearTrajectory(id);
    }
  }
}

六、自定义围栏与告警

6.1 围栏数据结构

// 围栏配置
const fenceConfig = {
  id: 'fence_001',
  name: 'A区禁区',
  type: 'polygon', // polygon | circle | corridor
  floor: 'F1',
  points: [
    { lng: 121.4737, lat: 31.2304 },
    { lng: 121.4740, lat: 31.2304 },
    { lng: 121.4740, lat: 31.2306 },
    { lng: 121.4737, lat: 31.2306 }
  ],
  // 告警规则
  rules: [
    {
      trigger: 'enter', // enter | exit | dwell
      targetTypes: ['person'], // person | device | vehicle
      targetIds: [], // 指定设备ID,空数组表示全部
      dwellTime: 0, // 停留时长阈值(秒),仅dwell触发时有效
      action: {
        type: 'alert', // alert | notify | webhook
        level: 'warning', // info | warning | critical
        message: '人员进入A区禁区',
        webhookUrl: 'https://api.example.com/alert'
      }
    },
    {
      trigger: 'dwell',
      targetTypes: ['person'],
      dwellTime: 300, // 停留超过5分钟
      action: {
        type: 'alert',
        level: 'critical',
        message: '人员在A区禁区停留超过5分钟'
      }
    }
  ]
};

6.2 围栏管理器

class FenceManager {
  constructor(viewer) {
    this.viewer = viewer;
    this.fences = new Map(); // fenceId -> fenceConfig
    this.fenceEntities = new Map(); // fenceId -> entity
    this.deviceStates = new Map(); // deviceId -> { inFences: Set }
    this.alertHandlers = [];
  }

  // 添加围栏
  addFence(config) {
    this.fences.set(config.id, config);
    this.renderFence(config);
  }

  // 渲染围栏可视化
  renderFence(config) {
    const { id, points, floor, type } = config;
    
    // 移除已有实体
    this.removeFenceEntity(id);

    let entity;
    
    if (type === 'polygon') {
      const hierarchy = points.map(p => 
        Cesium.Cartesian3.fromDegrees(p.lng, p.lat)
      );
      
      entity = this.viewer.entities.add({
        id: `fence_${id}`,
        polygon: {
          hierarchy,
          height: 0,
          material: Cesium.Color.RED.withAlpha(0.2),
          outline: true,
          outlineColor: Cesium.Color.RED,
          outlineWidth: 2
        }
      });
    } else if (type === 'circle') {
      entity = this.viewer.entities.add({
        id: `fence_${id}`,
        position: Cesium.Cartesian3.fromDegrees(config.center.lng, config.center.lat),
        ellipse: {
          semiMinorAxis: config.radius,
          semiMajorAxis: config.radius,
          material: Cesium.Color.ORANGE.withAlpha(0.2),
          outline: true,
          outlineColor: Cesium.Color.ORANGE
        }
      });
    }

    this.fenceEntities.set(id, entity);
  }

  // 检测位置是否在围栏内
  checkPosition(deviceId, position, deviceType) {
    const { lng, lat, floor } = position;

    for (const [fenceId, fence] of this.fences) {
      if (fence.floor && fence.floor !== floor) continue;

      const isInside = this.isPointInFence(lng, lat, fence);
      const wasInside = this.getDeviceFenceState(deviceId, fenceId);

      // 进入围栏
      if (isInside && !wasInside) {
        this.setDeviceFenceState(deviceId, fenceId, true);
        this.evaluateRules(fence, 'enter', deviceId, deviceType);
      }
      // 离开围栏
      else if (!isInside && wasInside) {
        this.setDeviceFenceState(deviceId, fenceId, false);
        this.evaluateRules(fence, 'exit', deviceId, deviceType);
      }
      // 停留检测
      else if (isInside && wasInside) {
        this.checkDwellTime(fence, deviceId, deviceType);
      }
    }
  }

  // 点是否在多边形内(射线法)
  isPointInFence(lng, lat, fence) {
    if (fence.type === 'circle') {
      const distance = this.calcDistance(
        lng, lat, fence.center.lng, fence.center.lat
      );
      return distance <= fence.radius;
    }

    const { points } = fence;
    let inside = false;
    
    for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
      const xi = points[i].lng, yi = points[i].lat;
      const xj = points[j].lng, yj = points[j].lat;
      
      const intersect = ((yi > lat) !== (yj > lat))
        && (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi);
      
      if (intersect) inside = !inside;
    }
    
    return inside;
  }

  evaluateRules(fence, trigger, deviceId, deviceType) {
    for (const rule of fence.rules) {
      if (rule.trigger !== trigger) continue;
      if (!rule.targetTypes.includes(deviceType)) continue;
      if (rule.targetIds.length > 0 && !rule.targetIds.includes(deviceId)) continue;

      this.executeAction(rule.action, {
        fenceId: fence.id,
        fenceName: fence.name,
        deviceId,
        deviceType,
        trigger
      });
    }
  }

  executeAction(action, context) {
    const alert = {
      ...action,
      ...context,
      timestamp: Date.now()
    };

    // 触发告警回调
    this.alertHandlers.forEach(handler => handler(alert));

    // Webhook通知
    if (action.type === 'webhook' && action.webhookUrl) {
      fetch(action.webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(alert)
      }).catch(err => console.error('Webhook通知失败:', err));
    }
  }

  onAlert(handler) {
    this.alertHandlers.push(handler);
  }

  getDeviceFenceState(deviceId, fenceId) {
    const state = this.deviceStates.get(deviceId);
    return state ? state.inFences.has(fenceId) : false;
  }

  setDeviceFenceState(deviceId, fenceId, isInside) {
    if (!this.deviceStates.has(deviceId)) {
      this.deviceStates.set(deviceId, { inFences: new Set() });
    }
    const state = this.deviceStates.get(deviceId);
    if (isInside) {
      state.inFences.add(fenceId);
    } else {
      state.inFences.delete(fenceId);
    }
  }

  removeFenceEntity(fenceId) {
    const entity = this.fenceEntities.get(fenceId);
    if (entity) {
      this.viewer.entities.remove(entity);
      this.fenceEntities.delete(fenceId);
    }
  }

  calcDistance(lng1, lat1, lng2, lat2) {
    // 简化距离计算,实际项目用Cesium.EllipsoidGeodesic
    const R = 6371000;
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLng = (lng2 - lng1) * Math.PI / 180;
    const a = Math.sin(dLat / 2) ** 2 +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLng / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }
}

6.3 围栏编辑UI

<template>
  <div class="fence-editor">
    <div class="fence-list">
      <div 
        v-for="fence in fences" 
        :key="fence.id"
        class="fence-item"
        @click="selectFence(fence)"
      >
        <span class="fence-name">{{ fence.name }}</span>
        <span class="fence-type">{{ fence.type }}</span>
        <button @click.stop="deleteFence(fence.id)">删除</button>
      </div>
    </div>
    
    <div class="fence-form" v-if="editingFence">
      <input v-model="editingFence.name" placeholder="围栏名称" />
      
      <select v-model="editingFence.type">
        <option value="polygon">多边形</option>
        <option value="circle">圆形</option>
      </select>
      
      <select v-model="editingFence.floor">
        <option value="">全部楼层</option>
        <option v-for="f in floors" :value="f.id">{{ f.name }}</option>
      </select>
      
      <!-- 告警规则 -->
      <div class="rules-section">
        <h4>告警规则</h4>
        <div v-for="(rule, index) in editingFence.rules" :key="index" class="rule-item">
          <select v-model="rule.trigger">
            <option value="enter">进入时</option>
            <option value="exit">离开时</option>
            <option value="dwell">停留超时</option>
          </select>
          
          <input 
            v-if="rule.trigger === 'dwell'" 
            v-model.number="rule.dwellTime" 
            type="number" 
            placeholder="停留秒数"
          />
          
          <select v-model="rule.action.level">
            <option value="info">信息</option>
            <option value="warning">警告</option>
            <option value="critical">严重</option>
          </select>
          
          <input v-model="rule.action.message" placeholder="告警消息" />
          
          <button @click="removeRule(index)">删除规则</button>
        </div>
        
        <button @click="addRule">添加规则</button>
      </div>
      
      <button @click="saveFence">保存</button>
    </div>
  </div>
</template>

七、踩坑记录

原因 解决方案
模型加载卡顿 单个文件过大 拆分成每层一个GLB,外部模型用3DTiles
IndexedDB写满 无清理机制 1GB上限 + LRU淘汰策略
定位漂移严重 UWB信号多径反射 融合WiFi + 惯性导航 + 卡尔曼滤波
围栏检测卡顿 每帧检测所有围栏 按楼层过滤 + 空间索引优化
轨迹回放卡顿 点位太多 按时间间隔采样 + Douglas-Peucker抽稀
MQTT消息堆积 处理太慢 只处理最新位置,丢弃过期消息

八、性能优化要点

  1. 模型分层加载:外部模型用3DTiles自动LOD,单层/内部模型用GLB按需加载
  2. 缓存策略:IndexedDB缓存已下载模型,1GB上限LRU淘汰
  3. 渲染优化:只渲染当前楼层,其他楼层卸载;视锥体外的实体隐藏
  4. 数据优化:MQTT消息去重,只处理最新位置;轨迹查询限制采样间隔
  5. 围栏优化:按楼层预过滤围栏,减少检测次数

九、总结

室内导航数字孪生的核心难点:

  1. 模型管理:多层级模型的加载、缓存、显隐控制,直接影响用户体验
  2. 定位精度:UWB原始数据漂移严重,必须做融合滤波
  3. 实时性:MQTT消息要即时渲染,但不能卡主线程
  4. 围栏告警:支持自定义规则,触发后及时通知

建议先跑通单层模型 + 实时定位的最小闭环,再逐步扩展多楼层、轨迹回放、围栏告警功能。


posted @ 2026-07-13 02:11  南珂丶一梦  阅读(7)  评论(0)    收藏  举报