数字孪生实战:从零搭建室内导航系统
数字孪生实战:从零搭建室内导航系统
最近做了一个室内导航数字孪生项目,涉及多模型分层加载、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消息堆积 | 处理太慢 | 只处理最新位置,丢弃过期消息 |
八、性能优化要点
- 模型分层加载:外部模型用3DTiles自动LOD,单层/内部模型用GLB按需加载
- 缓存策略:IndexedDB缓存已下载模型,1GB上限LRU淘汰
- 渲染优化:只渲染当前楼层,其他楼层卸载;视锥体外的实体隐藏
- 数据优化:MQTT消息去重,只处理最新位置;轨迹查询限制采样间隔
- 围栏优化:按楼层预过滤围栏,减少检测次数
九、总结
室内导航数字孪生的核心难点:
- 模型管理:多层级模型的加载、缓存、显隐控制,直接影响用户体验
- 定位精度:UWB原始数据漂移严重,必须做融合滤波
- 实时性:MQTT消息要即时渲染,但不能卡主线程
- 围栏告警:支持自定义规则,触发后及时通知
建议先跑通单层模型 + 实时定位的最小闭环,再逐步扩展多楼层、轨迹回放、围栏告警功能。
部分内容来源网络,如有侵犯到您的权益请联系leeke98@foxmail.com进行下架处理

浙公网安备 33010602011771号