Commit 83d33602 by James Yuan

feat: 照明聚光灯、场景压黑与大屏闪烁/开关写点修复

聚光灯朝地板并提高亮度,照明模式夜晚尽量全黑;避免 POI 重建闪烁;加固网关写点与 Modbus 模拟写支持,并补充 Docker 生产前端构建配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 07f3eebc
# 4eRTEngineHD Web SDK 接口文档
版本:2026.09.26(产品 **1.4.0**)
本文档描述通过 Web / 云渲染调用数字孪生引擎的 `$gisObj` API。典型链路:
```text
业务页 / 编辑器
→ iframe gis.html($gisObj)
→ 云渲染信令通道
→ 引擎命令处理
```
未列出的接口当前可能返回 `{ ok: true, noop: true, message: "deferred" }`,不会打断前端,但无引擎效果。
**已实现(摘要):** 视角 / FlyTo、**书签视点** `flyToBookmark`、**导览路径** `playTourPath` / `playRoute`、**场景动画** `playSceneAnimation` / `playAnimation`、场景多轨动画 `setSceneAnimation`、原点、Marker、**3D Tiles / GT 打包瓦片**(`load3dTiles` / `removeTiles`)、压平、图层列表、太阳时 / 天气、**地形全局水面** `loadGeoWaterLayer`、鼠标漫游、测量、热力图、**风场网格可视化**、粒子特效、场景灯光 `addLight`、运行时模型 + Gizmo、**模型材质预设**、Pak 叠加(Windows / Linux IoStore)、地面点击、**POI `modelClick`(GIS 字段对齐)**。
---
## 1. 整体说明与初始化
### 1.1 iframe 接入
```html
<iframe id="gisframe" src="http://127.0.0.1:88/gis.html" style="width:100%;height:100%;border:none"></iframe>
<script>
const frame = document.getElementById('gisframe')
frame.onload = () => {
frame.contentWindow.$onGisReady = () => {
const gis = frame.contentWindow.$gisObj
window.$gisObj = gis
gis.mouseClickCallback = (e) => console.log('mouseClick', e)
gis.modelClickCallback = (e) => console.log('modelClick', e)
}
}
</script>
```
也可直接在播放页脚本中赋值:
```js
window.$onGisReady = function () {
console.log('Engine is ready')
window.$gisObj.mouseClickCallback = mouseClickCallback
window.$gisObj.modelClickCallback = modelClickCallback
}
```
父页面还可通过 `postMessage` 调用,例如:
```js
iframe.contentWindow.postMessage({ type: 'setWeather', data: { weather: '7', rain: 0.8 } }, '*')
iframe.contentWindow.postMessage({ type: 'setSolarTime', data: { time: 12.30 } }, '*')
iframe.contentWindow.postMessage({ type: 'setViewGeo', data: { x: 106.58, y: 38.19, z: 2800, yaw: 0, pitch: -45 } }, '*')
```
---
## 2. 事件系统(已实现)
### 2.1 引擎就绪 `$onGisReady`
流与 GIS 脚本就绪后触发。请在此后再发起 API / 注册回调。
```js
window.$onGisReady = function () {
console.log('Engine is ready')
}
```
### 2.2 地面点击 `mouseClick`
短时左键点击空白地面(非拖拽)时推送。当前实现以**地理坐标**为主(经纬高)。
**回调**
```js
$gisObj.mouseClickCallback = (e) => {
// e.eventId === 'mouseClick'
// e.x / e.y / e.z 或 e.coord:经度、纬度、高度
console.log(e)
}
```
| 字段 | 说明 |
| --------------- | ----------------------------------------- |
| `eventId` | 固定 `mouseClick` |
| `x` / `y` / `z` | 经度、纬度、高度(米) |
| `coord` | 同上,并含 `longitude` / `latitude` / `height` |
| `metaData` | JSON 字符串(简要坐标) |
### 2.3 模型 / POI 点击 `modelClick`
点击 Marker(POI)时推送。按屏幕上的图标区域判定(与画面一致)。
| 回包字段 | 来源 |
| ---------------------------------------------------- | ------------------- |
| `id` / `tag0` / `Tag0` | Marker `id` |
| `tag1` / `Tag1` | 添加时的 `tag0` |
| `tag2` / `Tag2` | 添加时的 `tag1` |
| `tag3` / `Tag3` | 添加时的 `tag2` |
| `tag4` / `Tag4` | 添加时的 `tag3` |
| `tag5`–`tag10` / `Tag5`–`Tag10` | 添加时的 `tag4`–`tag9` |
| `tags` | 自定义键值 Tag 对象 |
| `name` / `type` | 显示名、类型(默认 `marker`) |
| `Sender` | 固定 `Model` |
| `mouseX` / `mouseY` / `screenWidth` / `screenHeight` | 点击位置与视口 |
```js
$gisObj.modelClickCallback = (e) => {
// e = {
// eventId: 'modelClick',
// id: 'poi-001',
// tag0 / Tag0: 'poi-001',
// tag1 / Tag1: 'Devices', // addMarker.tag0
// tag2 / Tag2: 'F1', // addMarker.tag1
// tag3 / Tag3: 'cam', // addMarker.tag2
// tag4 / Tag4: 'south', // addMarker.tag3
// tag5–tag10: addMarker.tag4–tag9
// tags: { building: 'A' },
// name: '东门',
// type: 'marker',
// Sender: 'Model',
// mouseX: '918.9', mouseY: '1005',
// screenWidth: '1920', screenHeight: '1080'
// }
}
```
### 2.4 通用响应
多数命令经云渲染回包,形如:
```json
{ "eventId": "setViewGeo", "ok": true }
```
失败时:
```json
{ "eventId": "...", "ok": false, "message": "..." }
```
未实现命令:
```json
{ "eventId": "<command>", "ok": true, "noop": true, "message": "deferred" }
```
带回调的查询(如 `getViewGeo`)由 `$gisObj` 内部把回包转给 callback。
---
## 3. 基本接口(已实现)
以下示例中 `gis` 均指 `window.$gisObj`(或 iframe 内同名对象)。
### 3.1 获取 / 设置视角(本地坐标别名)
```js
gis.getView(callback)
gis.setView(x, y, z, yaw, pitch, armLength)
```
当前引擎侧 `**getView` / `setView` 与经纬度接口等价**(按地理坐标处理)。推荐直接使用 3.2。
### 3.2 获取 / 设置经纬度视角
```js
gis.getViewGeo((e) => {
// e.x 经度, e.y 纬度, e.z 高度, e.yaw, e.pitch, e.armLength
console.log(e)
})
// 默认约 2s Cesium FlyTo(椭球弧线路径)
gis.setViewGeo(longitude, latitude, height, yaw, pitch, armLength)
// 指定时长(秒)
gis.setViewGeo(106.58, 38.19, 2800, 0, -45, 0, 3)
// 速度 / 路径控制
gis.setViewGeo(106.58, 38.19, 2800, 0, -45, 0, {
duration: 4, // 飞行秒数(与 speed 二选一;speed 优先)
speed: 0, // 米/秒;>0 时按球面距离自动算时长
maxHeight: 2000, // 弧线最高抬升(米);0=直线抬升,省略=按距离自适应弧线
path: 'arc', // 'arc' | 'linear'
instant: false, // true=瞬移
canInterrupt: true // 用户拖动相机可打断
})
```
| 参数 | 说明 |
| ------------------------- | --------------------------------------- |
| `x` / longitude | 经度 |
| `y` / latitude | 纬度 |
| `z` / height | 椭球高(米) |
| `yaw` | 偏航(度) |
| `pitch` | 俯仰(度,俯视多为负值) |
| `armLength` | 臂长 / 距离相关参数(兼容字段,加到高度上) |
| `duration` | 飞行时长(秒),默认 `2`;`0` 或 `instant:true` 为瞬移 |
| `speed` | 平均速度(米/秒),`>0` 时覆盖 `duration` |
| `maxHeight` / `flyHeight` | 弧线峰值抬升高度(米) |
| `path` | `arc`(默认弧线)/ `linear`(高度线性插值) |
相机沿椭球弧线飞行(含高度剖面),同时插值朝向。
等价命令:`flyToPos` / `moveToPosGeo`(`$gisObj.flyTo(...)` 同样走 FlyTo)。
```js
gis.flyTo(106.58, 38.19, 2800, 0, -45, 0, { speed: 800, maxHeight: 1500 })
```
### 3.2.0 书签视点(flyToBookmark)
编辑器「地图书签」与场景 JSON 的 `bookmarks[]` 字段同源。SDK 侧用内存注册表保存书签,再按 **id 或 name**(或 camera 对象)飞到指定视角。
**典型流程:**
1. 从场景 JSON 或接口拉取 `bookmarks[]`
2. `registerBookmarks(bookmarks)` 注册
3. `flyToBookmark(id 或 name)` 飞到指定书签
```js
// 1) 注册(可多次合并;打开场景时会自动 registerBookmarks / registerTourPaths)
gis.registerBookmarks([
{
id: 'bm-001',
name: '南门',
camera: {
lng: 106.58, lat: 38.19, height: 2800,
heading: 0, pitch: -45, roll: 0,
duration: 1.2 // 飞行秒数;0=瞬移
}
}
])
// 2) 飞到书签(id / name / 完整对象 / camera 均可;字符串优先按 id 匹配,再按 name)
gis.flyToBookmark('bm-001')
gis.flyToBookmark('南门')
gis.flyToBookmark('bm-001', { duration: 1.2, speed: 800 })
gis.flyToViewpoint({ camera: { lng: 106.58, lat: 38.19, height: 1500, heading: 90, pitch: -30 } })
// 3) 列出已注册书签(须调用云渲染 iframe 内的 $gisObj,不要用父页面自己的副本)
gis.listBookmarks((res) => console.log(res.bookmarks, res.length))
// 或:const res = await gis.listBookmarks()
```
| 方法 | 说明 |
| ------------------------------------------------------------- | ------------------------ |
| `registerBookmarks(bookmarks[])` | 注册/合并书签 |
| `listBookmarks(callback?)` | 返回 `{ ok, bookmarks[] }` |
| `flyToBookmark(id | name | bookmark | camera, options?, cb?)` | 飞到视点;别名 `flyToViewpoint` |
| `options` | 说明 |
| ----------- | ------------------------------------------------------------------ |
| `duration` | 飞行时长(秒),默认取书签 `camera.duration`(缺省 `1.2`);`0` 或 `instant:true` 为瞬移 |
| `speed` | 平均速度(米/秒),>0 时覆盖 duration |
| `maxHeight` | 弧线抬升高度(米);直线路径请用 `path:'linear'` |
| `instant` | 是否瞬移 |
| `path` | `'linear'`(默认)或 `'arc'` |
书签 `camera` 字段与编辑器一致:优先 `lng/lat/height/heading/pitch/duration`(`lon` 可作为经度别名);旧数据可用 `x/y/z/yaw`(当 x/y 为经纬度时)。`time`/`solarTime` 不再表示飞行时长。
**调用时机:** 仅传 id / name 时,须等场景打开完成(`listBookmarks` 非空)后再飞。打开场景会自动注册;若过早调用,会排队并在注册完成后自动补飞。也可直接传 `{ camera: { lng, lat, height, heading, pitch } }`,不依赖注册表。
### 3.2.1 导览路径(playTourPath / playRoute)
引擎内部按折线**弧长匀速插值**播放(段间距可变,全程速度恒定)。与编辑器「导览路径」面板、`tourpaths[]` 字段同源。
```js
// 0) 从场景 JSON 注册导览路径(≥2 点/条;打开场景时自动执行)
gis.registerTourPaths([
{
id: 'tour-01',
name: '园区环线',
speed: 500, // cm/s,可选;播放时可覆盖
points: [
{ lng: 106.58, lat: 38.19, height: 1500, heading: 0, pitch: -30 },
{ lng: 106.59, lat: 38.20, height: 1400, heading: 45, pitch: -35 },
{ lng: 106.60, lat: 38.19, height: 1600, heading: 90, pitch: -25 }
]
}
])
// 1) 保存路径(至少 2 点)
gis.setRoute('Route-01', [
{ x: 106.58, y: 38.19, z: 1500, yaw: 0, pitch: -30 },
{ x: 106.59, y: 38.20, z: 1400, yaw: 45, pitch: -35 },
{ x: 106.60, y: 38.19, z: 1600, yaw: 90, pitch: -25 }
])
// 2) 播放导览:speed 为兼容单位 cm/s(默认 500 → 5 m/s)
gis.playTourPath('tour-01', 500, (e) => {
// e.status: started | paused | resumed | stopped | Route-End
console.log(e.status, e.id)
})
// 别名:playRoute / playTour
// 也可一次传入点列(无需先 registerTourPaths)
gis.playTourPath({
id: 'Route-01',
speed: 500,
// speedMeters: 5, // 显式米/秒,优先于 speed
points: [ /* ... */ ]
}, (e) => console.log(e))
gis.pauseRoute('Route-01')
gis.resumeRoute('Route-01')
gis.stopRoute('Route-01')
gis.removeRoute('Route-01')
gis.listRoutes((res) => console.log(res.routes))
```
| 方法 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `registerTourPaths(paths[])` | 注册 `tourpaths[]` 到引擎(内部 `setRoute`) |
| `playTourPath(id, speed?, cb?)` | 播放导览;别名 `playRoute` / `playTour` |
| `pauseRoute` / `resumeRoute` / `stopRoute` | 暂停 / 继续 / 停止 |
| `setRoute` / `listRoutes` / `removeRoute` | 路径 CRUD |
| 参数 | 说明 |
| -------------------------- | --------------------------------------------------------- |
| `id` | 路径 ID |
| `points[]` | `{x,y,z,yaw,pitch}` 或 `longitude/latitude/height/heading` |
| `speed` | 速度(兼容),**cm/s**(100–2000,默认 500) |
| `speedMeters` / `speedMps` | 米/秒,若给出则覆盖 `speed` |
RPC:`route.set` / `route.list` / `route.delete` / `route.play` / `route.pause` / `route.resume` / `route.stop`。
**编辑器:**「导览路径」面板提供 **生成视频**:边播放边录制云渲染画面,结束后可另存为 MP4(非 `$gisObj` API)。
### 3.2.2 场景动画(playSceneAnimation / playAnimation)
与编辑器「场景动画」面板同源:相机 / 模型 / 粒子 / **模型骨骼** / **刚性节点(轴)**多轨道 + 关键帧时间轴。编辑器保存的场景 JSON 字段为 `animations[]`,打开场景时会自动 `setSceneAnimation` 预注册。
轨道 `property`:`transform`(位姿)| `visible`(显隐)| `boneTransform`(骨骼局部变换,需 `boneName`)| `nodeTransform`(刚性节点/轴局部变换,需 `nodeName` 或 `boneName`)。播放时对骨骼/节点关键帧做插值。
**快速播放(clip 已注册):**
```js
// 播放已注册的场景动画(编辑器导出或 setSceneAnimation 预注册)
gis.playSceneAnimation('intro-01')
gis.playAnimation({ id: 'intro-01', time: 0, speed: 1 }, (e) => {
// e.status: playing | paused | stopped | ended | seeked
console.log(e.status, e.time, e.id)
})
gis.pauseSceneAnimation('intro-01')
gis.resumeSceneAnimation('intro-01')
gis.stopSceneAnimation('intro-01')
```
**完整 clip 定义与注册:**
```js
const clip = {
id: 'intro-01',
name: '开场',
duration: 10,
loop: false,
coordinateMode: 'geo',
tracks: [
{
id: 'trk-cam',
targetId: 'camera',
targetKind: 'camera', // camera | model | effect
property: 'transform', // transform | visible | boneTransform | nodeTransform
// boneTransform 时需 boneName: 'spine_01'
// nodeTransform 时需 nodeName: 'Axis1'(亦可写 boneName,与编辑器字段兼容)
keyframes: [
{
time: 0,
easing: 'linear', // linear | step | easeInOut | easeIn | easeOut
value: { x: 106.58, y: 38.19, z: 1500, yaw: 0, pitch: -45, roll: 0, scale: 1, isGeo: true }
},
{
time: 8,
easing: 'easeInOut',
value: { x: 106.60, y: 38.20, z: 1200, yaw: 40, pitch: -35, roll: 0, scaleX: 1.5, scaleY: 1, scaleZ: 0.8, isGeo: true }
}
]
}
]
}
gis.setSceneAnimation(clip, (r) => console.log('set', r))
gis.seekSceneAnimation({ id: 'intro-01', time: 3.5, apply: true })
gis.listSceneAnimations((r) => console.log(r))
```
| 方法 | 说明 |
| --------------------------------------------------------------------- | --------------------- |
| `setSceneAnimation(clip, cb?)` | 注册/更新动画 clip |
| `playSceneAnimation(id | opts, cb?)` | 播放;别名 `playAnimation` |
| `pauseSceneAnimation` / `resumeSceneAnimation` / `stopSceneAnimation` | 播放控制 |
| `seekSceneAnimation({ id, time, apply })` | 跳转到指定时间 |
| `listSceneAnimations` / `getSceneAnimation` / `removeSceneAnimation` | 动画 CRUD |
| 参数 | 说明 |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `clip.id` | 动画 ID(场景内唯一) |
| `clip.loop` | 是否循环 |
| `clip.autoPlay` | `true` 时打开场景后自动播放该动画(别名 `playOnStart`) |
| `tracks[].targetKind` | `camera` / `model` / `effect` |
| `tracks[].targetId` | 相机用 `"camera"`;模型/粒子为组件 ID |
| `tracks[].property` | `transform` / `visible` / `boneTransform` / `nodeTransform`(别名 `node` / `axis`) |
| `tracks[].boneName` | `boneTransform` 时必填,骨骼名;`nodeTransform` 亦可填节点名(兼容) |
| `tracks[].nodeName` | `nodeTransform` 时推荐填写,节点/轴名(如 `Axis1`) |
| `keyframes[].value` | 变换:`x/y/z/yaw/pitch/roll/isGeo`;缩放可用均匀 `scale`,或分轴 `scaleX`/`scaleY`/`scaleZ`;骨骼/节点为局部变换(`isGeo:false`);可见:`boolean` |
| `play.speed` | 播放倍速,默认 1 |
编辑器「生成路径动画」会按场景折线为模型写入 `transform` 关键帧(朝向对齐切线);头尾颠倒时可设朝向偏置 `yawOffset`(度,常见 `180`)。生成结果仍是普通 `animations[]` clip,用上表接口播放即可。
RPC:`scene.animation.set` / `play` / `pause` / `resume` / `stop` / `seek` / `list` / `get` / `remove` / `clear`。
状态事件:`eventId: 'sceneAnimation'`。
**编辑器:**「场景动画」面板提供 **生成视频**:从 0 秒播到结束并录制云渲染画面,可另存为 MP4(非 `$gisObj` API)。
### 3.3 原点
```js
gis.setOriginLocation(longitude, latitude, height)
gis.getOriginLocation(callback)
// 将场景原点与天气中心对齐到 3DTiles 图层
gis.recenterToLayer('tiles-chemistry')
// 或直接指定经纬度
gis.recenterToLayer({ longitude: 106.58, latitude: 38.19, height: 1300 })
```
`setOriginLocation` 会同步更新场景天气中心到新原点。
`overlook` / `flyToTileset` 默认会先 `recenterOrigin`(可用 `{ recenterOrigin: false }` 关闭)。
### 3.3.1 场景全局配置 `setSceneConfig`
可随时部分更新(与 `setWeather` 一样只传需要改的字段)。别名:`setGlobalConfig` / `getGlobalConfig`。
```js
gis.setSceneConfig({
showEarth: true,
// 影像 XYZ 模板。默认天地图(需 {reverseY} + {tk})
imageryUrl: 'https://t0.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={reverseY}&l={z}&tk={tk}',
imageryLabelUrl: 'https://t0.tianditu.gov.cn/DataServer?T=cia_w&x={x}&y={reverseY}&l={z}&tk={tk}',
tiandituTk: 'your-token',
// ArcGIS 示例(注意用 {y} 而非 {reverseY}):
// imageryUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
// imageryLabelUrl: '',
// 本地 XYZ 示例: 'http://127.0.0.1:8080/{z}/{x}/{y}.png'
doubleClickFlyDistanceMeters: 25,
// 鼠标导航灵敏度(小室内场景建议调低)
panSensitivity: 0.55, // 左键拖动;别名 dragSensitivity
zoomFactor: 5, // 滚轮缩放倍率;别名 zoomSensitivity
rotateDegreesPerPixel: 0.06, // 右键旋转(度/像素);别名 orbitSensitivity
wheelNotchRatio: 0.02, // 滚轮单格比例;步进 ≈ zoomFactor × wheelNotchRatio
mapMaximumLevel: 16, // 地图/影像最大级别,默认 16(别名 basemapMaximumLevel)
maxRain: 5, // 雨量倍率,默认 5
maxSnow: 5, // 雪量倍率,默认 5
shadowsEnabled: true, // 动态阴影开关;false 等价于 shadowDistanceMeters=0
shadowDistanceMeters: 2000, // 平行光动态阴影距离(米),默认 2000;0=关闭阴影
timeZoneHours: 8, // UDS 时区(相对 UTC 小时,东为正),与场景原点一起决定太阳方位
// POI / 车位距离剔除(0=关闭剔除)
poiCullDistanceMeters: 1200,
poiSpawnPerFrame: 12,
poiPoolMax: 256,
// 3D Tiles LOD(屏幕空间误差):越大越粗越流畅,越小越清晰
spaceError: 16,
adaptiveScreenSpaceError: true,
showRenderStats: false // 场景底部显示 FPS / 三角面 / 顶点等
})
gis.getSceneConfig((cfg) => console.log(cfg))
```
| 字段 | 说明 |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `showEarth` | 是否显示地球(tileset `default_terrain` + 叠影像) |
| `imageryUrl` / `basemapUrl` | 影像 URL 模板;`{tk}` 由 `tiandituTk` / 配置项替换 |
| `imageryLabelUrl` / `basemapLabelUrl` | 注记层;传 `''` 去掉注记 |
| `tiandituTk` | 天地图密钥 |
| `mapMaximumLevel` / `basemapMaximumLevel` | 地图(影像)最大级别,默认 **16**,范围 1–30 |
| `maxRain` / `rainAmountScale` | 全局雨量倍率(默认 **5**) |
| `maxSnow` / `snowAmountScale` | 全局雪量倍率(默认 **5**) |
| `doubleClickFlyDistanceMeters` | 鼠标双击飞到目标的斜距(米,默认 10,范围 2–50000) |
| `panSensitivity` / `dragSensitivity` | 左键拖动平移灵敏度,默认 **0.55**,范围 0.05–5;小室内场景可调到 0.2–0.35 |
| `zoomFactor` / `zoomSensitivity` | 滚轮缩放倍率,默认 **5**,范围 0.5–20;小室内场景可调到 1–2;单格步进 ≈ `zoomFactor × wheelNotchRatio` |
| `rotateDegreesPerPixel` / `orbitSensitivity` | 右键旋转灵敏度(度/像素),默认 **0.06**,范围 0.01–0.5;小室内场景可调到 0.02–0.04 |
| `wheelNotchRatio` | 滚轮单格比例,默认 **0.02**,范围 0.005–0.1 |
| `shadowsEnabled` / `enableShadows` | 动态阴影开关(默认 **true**);`false` 关闭阴影系统 |
| `shadowDistanceMeters` / `shadowDistance` | 平行光动态阴影距离(米,默认 **2000**,范围 **0–100000**);**0** = 关闭阴影系统;过小则需贴近建筑才见阴影 |
| `timeZoneHours` / `timeZone` / `timezone` | UDS 时区(相对 UTC 的小时,东为正,默认 **8**,范围 -12–14)。与场景原点经纬度一起决定天空太阳位置;调节 24 小时太阳时时太阳会出现在正确方位 |
| `spaceError` / `maximumScreenSpaceError` | 全局 3D Tiles 屏幕空间误差(LOD),默认 **16**,范围 **4–64**;越大越粗越流畅、越小越清晰。写入后立即作用于已加载业务瓦片;`load3dTiles` 未指定时也用此默认值 |
| `adaptiveScreenSpaceError` / `adaptiveSSE` | 运动自适应 SSE,默认 **true**:极速移动时短暂抬高误差保帧率,静止后回到 `spaceError`;普通滚轮/平移不会突然变糊 |
| `showRenderStats` | 是否在场景底部显示渲染统计 HUD(FPS / DrawCall / 三角面 / 顶点),默认 **false**;也可用 `showRenderStats` / `setRenderStatsVisible` |
| `poiCullDistanceMeters` | Marker/车位距离剔除半径(米,默认 **1200**;`0`=关闭剔除) |
| `poiSpawnPerFrame` | 每帧最多激活/创建对象数(默认 **12**) |
| `poiPoolMax` | 空闲对象池上限(默认 **256**) |
### 3.3.2 渲染统计 HUD `showRenderStats` / `getRenderStats`
在场景**底部**显示本帧渲染细节(与相机条叠放)。默认关闭,由 SDK 开关:
```js
// 显示
gis.showRenderStats(true)
// 或
gis.setRenderStatsVisible({ visible: true })
gis.setSceneConfig({ showRenderStats: true })
// 隐藏
gis.hideRenderStats()
gis.setRenderStatsVisible(false)
// 读一次数值(不依赖 HUD)
gis.getRenderStats((s) => console.log(s))
// → { fps, frameMs, gameMs, renderMs, gpuMs, drawCalls,
// triangles, primitives, meshTriangles, meshVertices, vertices, visible }
```
| 字段 | 说明 |
| --------------------------------------------- | ----------------------------------- |
| `fps` / `frameMs` | 帧率与整帧耗时(ms) |
| `gameMs` / `renderMs` / `gpuMs` | 游戏线程 / 渲染线程 / GPU(有 StatUnit 时) |
| `drawCalls` | 本帧 DrawCall |
| `triangles` / `primitives` | 本帧 RHI 绘制图元(通常为三角面) |
| `meshTriangles` / `meshVertices` / `vertices` | 场景中可见 StaticMesh LOD0 合计(约 1s 采样一次) |
| `visible` / `showRenderStats` | 当前 HUD 是否开启 |
### 3.4 Marker(POI)
#### 添加
```js
gis.addMarker({
id: 'poi-001',
name: '东门',
// 推荐:经纬度
x: 106.585793,
y: 38.191845,
z: 1314,
isGeo: true,
// 或 position: [lon, lat, height]
icon: 'cctv.png', // 从 icons/ 目录加载
tag0: 'Devices',
tag1: 'F1',
tag2: '',
tags: { building: 'A', extra: 'south' }, // 自定义键值,可增删
showText: true, // false 时只显示图标
leader: false, // 地面到图标的引线
leaderHeight: 2, // 米(沿线长度)
leaderThickness: 6, // 厘米
leaderTilt: 0, // 偏离地表法线的倾角(度),0=竖直
leaderYaw: 0, // 倾角的水平朝向(度)
leaderFlow: false, // 引线发光流动
leaderFlowSpeed: 1, // 流动频率(Hz)
radar: false, // 地面扩散雷达波
scan: false, // 地面旋转扫描线
radarRadius: 20, // 米
radarWaves: 3, // 同时存在的波圈数(1–4)
radarFrequency: 0.5, // Hz
radarTrail: 0.55, // 拖影强度 0–1
radarColor: { r: 51, g: 217, b: 255, a: 217 }, // a = 透明度 0–255
leaderColor: { r: 255, g: 255, b: 255, a: 255 },
scale: 1.0,
color: { r: 255, g: 255, b: 255, a: 255 }, // 图标着色
textColor: { r: 255, g: 255, b: 255, a: 255 }, // 文字前景色
textBgColor: { r: 13, g: 20, b: 36, a: 217 } // 文字背景色(也可用 '#0d1424d9')
})
gis.addMarkerByType({ /* 同 addMarker */ })
```
| 字段 | 说明 |
| ------------------------------------------------------------------ | ------------------------------------------------------ |
| `id` | 必填,唯一 ID |
| `name` / `text` | 显示名(`showText:false` 时仍可保留,但不画文字) |
| `showText` / `showLabel` | 默认 `true`;`false` 时只显示图标 |
| `x,y,z` 或 `longitude,latitude,height` 或 `position[]` | 地理坐标(`isGeo:true`,默认) |
| `isGeo` | `false` 时 `x,y,z` 为引擎世界坐标(厘米) |
| `icon` | 图标文件名,如 `cctv.png`;从 `icons/` 目录加载(打包根/`ProjectDir` 旁) |
| `tag0`…`tag9` | 编号标签(兼容旧 `tag0`–`tag3`) |
| `tags` | 自定义键值,对象 `{ key: value }` 或数组 `[{ key, value }]` |
| `leader` / `showLeader` | 是否绘制地面到图标的引线 |
| `leaderHeight` | 引线长度(米,默认 2) |
| `leaderTilt` / `leaderAngle` | 相对地表法线的倾角(度,默认 0=沿法线) |
| `leaderYaw` / `leaderHeading` | 倾角水平朝向(度,默认 0) |
| `leaderFlow` | 引线发光流动 |
| `leaderFlowSpeed` | 流动频率(Hz,默认 1) |
| `radar` / `scan` | 地面雷达扩散波 / 旋转扫描线(发光材质) |
| `radarRadius` / `auraRadius` | 效果半径(米,默认 20) |
| `radarWaves` / `waveCount` | 同时存在的雷达波圈数(1–8,默认 3) |
| `radarFrequency` / `frequency` | 雷达/扫描频率(Hz,默认 0.5) |
| `radarColor` / `auraColor` | 雷达/扫描颜色(`a` 为透明度 0–255) |
| `leaderOffsetX` / `leaderOffsetY` | 可选,水平偏移(米);未写倾角时由此换算 `leaderTilt`/`leaderYaw` |
| `leaderThickness` / `leaderWidth` | 引线直径(厘米,默认 6) |
| `leaderColor` | 引线颜色 |
| `type` / `color` / `scale` | 可选外观(`color`=图标着色;`scale<1` 时图标走 mip 滤波) |
| `textColor` / `fontColor` / `labelColor` | 文字前景色(`{r,g,b,a}` 0–255 或 `#RRGGBB[AA]`) |
| `textBgColor` / `textBackgroundColor` / `labelBgColor` / `bgColor` | 文字背景色 |
| `anchor` / `achor` | 文字屏幕偏移 `{x,y}`,默认 `{x:0,y:0}` |
Marker 落点处带柔和接触阴影,并以隐藏圆盘向场景投射动态阴影(随 `scale` 缩放;隐藏 Marker 时一并关闭)。鼠标悬停图标区域时整体放大 10%,移开复原。
#### 更新 / 删 / 清
```js
gis.updateMarker({ id: 'poi-001', name: '新名称', x: 106.58, y: 38.19, z: 1314 })
gis.updateMarkerText('poi-001', '新文字')
gis.addMarkerTag({ id: 'poi-001', key: 'building', value: 'A' })
gis.removeMarkerTag({ id: 'poi-001', key: 'building' })
gis.setMarkerTags({ id: 'poi-001', tags: { building: 'A' }, tag0: 'Devices' })
gis.getMarker('poi-001')
gis.removeMarker('poi-001')
gis.clearMarkers()
```
#### 飞到 / 选中
```js
gis.flyToMarker('poi-001')
gis.selectMarker('poi-001') // 或 selectPOI
```
#### 显隐
```js
// 按 tag 批量(tag 可空串 / 'null' / '*' 匹配全部)
gis.showPOIs(tag0, tag1, tag2)
gis.hidePOIs(tag0, tag1, tag2)
// 单条
gis.setPoiVisible({ id: 'poi-001', visible: false })
gis.setParkingVisible({ id: 'park-001', visible: true })
// 分组
gis.listPoiGroups() // → { groups:[{ tag0, tag1, count, type:'marker'|'parking', visible }] }
gis.setPoiGroupVisible({ tag0: 'Devices', tag1: 'null', visible: false, kind: 'marker' })
// 批量添加(引擎侧写入 Desc 后分帧创建,避免一帧 Spawn 上千)
gis.addMarkers({ markers: [ /* 同 addMarker 字段 */ ] })
```
| 命令 | 说明 |
| --------------------------------------------------- | ----------------------------------------- |
| `showPOIs` / `hidePOIs` | 按 `tag0/1/2` 批量显隐 Marker |
| `setMarkerVisible` / `setPoiVisible` | 指定 `id` 或 tag;`visible`/`show` 布尔 |
| `listPoiGroups` | 聚合 Marker/车位的 tag0/tag1 分组与数量 |
| `setPoiGroupVisible` | 按 tag + 可选 `kind`(marker/parking/all)批量显隐 |
| `addMarkers` | 批量添加;内部走对象池与分帧 Spawn |
| `showParking` / `hideParking` / `setParkingVisible` | 车位显隐(支持 id 或 tag) |
### 3.5 3D Tiles(含 GT 打包瓦片)
标准 3D Tiles(`tileset.json` + 散落 b3dm 等)与 **GT 打包瓦片**(CesiumLab 等工具产出的整包网格)使用**同一套接口**:`load3dTiles` / `loadTiles` / `removeTiles`。业务侧无需区分加载命令。
```js
// 推荐
gis.load3dTiles(layerId, url, spaceError)
gis.load3dTiles(layerId, url, options)
gis.load3dTiles(layerId, url, spaceError, options)
// 别名
gis.loadTiles(layerId, url, spaceErrorOrOptions, options?)
gis.removeModel(layerId) // 按 id 移除 tiles(兼容旧 removeModel 名)
gis.removeTiles(layerId) // 同上
```
| 参数 | 说明 |
| ------------ | ---------------------------------------------------------- |
| `layerId` | 图层 ID,不可重复 |
| `url` | `tileset.json` 地址(http(s) 绝对地址,或本机托管的 `/basemaps/...`,见下) |
| `spaceError` | 可选,屏幕空间误差(同 `maximumScreenSpaceError`);默认取 `setSceneConfig.spaceError`(引擎默认 **16**) |
| `options` | 可选对象,见下表 |
#### 本机 `/basemaps` 托管(terrain / 3D Tiles / GT)
编辑器信令可将本机瓦片目录映射为 HTTP:
- 场景或 API 中写相对路径:`/basemaps/<子目录>/tileset.json`(或 `layer.json`)
- 运行时会解析为 `http://127.0.0.1:<端口>/basemaps/...`(引擎需要绝对 URL)
- 磁盘根目录默认探测 `F:\tiles`、`D:\tiles` 等;可用环境变量 `**RTENGINE_BASEMAPS_ROOT**`(或 `BASEMAPS_ROOT`)指定,例如 `D:\tiles`
目录示例:
```text
{RTENGINE_BASEMAPS_ROOT}/
HK_Terrain/layer.json ← 地形 / 水面
Kowloon_NT_Mesh/tileset.json ← 3D Tiles 或 GT 打包瓦片
```
```js
// 标准远程 3D Tiles
gis.load3dTiles('chemistry', 'https://example.com/tiles/chemistry/tileset.json', 16)
// 本机 GT / 3D Tiles(相对路径即可;也可用完整 http URL)
gis.load3dTiles('kowloon', '/basemaps/Kowloon_NT_Mesh/tileset.json', {
maximumScreenSpaceError: 16,
maximumSimultaneousTileLoads: 12,
createPhysicsMeshes: false
})
gis.removeTiles('kowloon')
```
场景图层类型为 `3dtiles` 时,打开场景会自动调用上述加载逻辑;GT 与散落瓦片在图层库 / 属性面板中同样按 `3dtiles` 配置。
| options / paramObj 字段 | 说明 |
| ---------------------------------------------------------- | -------------------------------------------- |
| `maximumScreenSpaceError` / `spaceError` | 静止时的 LOD 屏幕空间误差,越大越粗、越省;有效范围约 **≥4**(再低会被夹到 4) |
| `adaptiveScreenSpaceError` / `adaptiveSSE` | 默认开启:快速缩放/平移时自动抬高 SSE 保流畅,静止后降回 `spaceError` 拉高清晰度 |
| `maximumMemoryUsage` / `maximumMemoryUsageMB` | 缓存上限(**MB**),映射到 Cesium `MaximumCachedBytes`;城市场景建议 **2048** |
| `maximumCachedBytes` | 缓存上限(字节),优先于 MB |
| `maximumSimultaneousTileLoads` | 同时加载瓦片数;城市场景建议 **16** |
| `loadingDescendantLimit` | 等待子级细化前的加载后代上限;城市场景建议 **16** |
| `preloadAncestors` / `preloadSiblings` | 是否预加载祖先/兄弟瓦片;高密度城区建议兄弟 **false** |
| `forbidHoles` | 未加载完时用父级填洞;城市场景建议 **true**(减闪洞/卡顿) |
| `enableFrustumCulling` / `enableFogCulling` | 视锥/雾裁剪 |
| `enforceCulledScreenSpaceError` / `culledScreenSpaceError` | 被裁剪瓦片的 SSE 策略 |
| `createPhysicsMeshes` | 是否生成物理网格(拾取/贴地需要;仅浏览可 `false` 减卡顿) |
```js
gis.load3dTiles(
'chemistry',
'https://iot.raytue.com/tiles/chemistry/tileset.json',
16
)
gis.load3dTiles('chemistry', 'https://iot.raytue.com/tiles/chemistry/tileset.json', {
maximumScreenSpaceError: 16,
maximumMemoryUsage: 512, // MB
maximumSimultaneousTileLoads: 20,
preloadAncestors: true,
createPhysicsMeshes: false
})
// 全港 / 港岛等高密度倾斜:优先流畅,静止后再出高 LOD
gis.load3dTiles('hk', 'https://example.com/hk/tileset.json', {
maximumScreenSpaceError: 16,
adaptiveScreenSpaceError: true,
maximumMemoryUsage: 2048,
maximumSimultaneousTileLoads: 16,
loadingDescendantLimit: 16,
forbidHoles: true,
preloadAncestors: true,
preloadSiblings: false,
createPhysicsMeshes: true
})
```
#### 显隐
```js
// Legacy / 引擎命令(也可经 emitMessage)
// setTilesVisible / showTiles / hideTiles / setLayerVisible
ps.emitMessage({ command: 'setTilesVisible', paramObj: { id: 'chemistry', visible: false } })
ps.emitMessage({ command: 'hideTiles', paramObj: { layerId: 'chemistry' } })
```
| 命令 | 说明 |
| ------------------------------------- | -------------------------------------- |
| `setTilesVisible` / `setLayerVisible` | `{ id` 或 `layerId`, `visible`/`show` } |
| `showTiles` / `hideTiles` | 同上,命令名本身表示显/隐 |
#### 区域压平(Flatten)
对 3D Tiles / 地形 / **地形水面**(`geo-water` 图层):按多边形裁切 **`tilesetId` 指定的那一层**(不会裁其它 3D Tiles),并用顶视捕获的**瓦片/影像纹理**铺压平板。要从水面露出隧道/低凹陆地,请把 `tilesetId` 设为地形水面图层,不要设倾斜摄影。水面压平高度可为 0(海平面)。
```js
gis.flattenTiles({
id: 'flatten-1',
tilesetId: 'default_terrain', // 或业务 3DTiles / geo-water 图层 id
height: 1310.5, // 椭球高 (m),可负
useTilesTexture: true, // 默认 true:纹理压平板
geometry: {
type: 'Polygon',
coordinates: [[[lng, lat], [lng2, lat2], [lng3, lat3], [lng, lat]]]
}
})
gis.removeFlatten('flatten-1')
gis.clearFlatten()
gis.listFlatten()
```
编辑器顶栏「压平」:选择已绘制多边形 + 图层 + 高度后应用;写入场景 `components`(`type: tiles-flatten`)。
### 3.6 图层列表
```js
gis.getLayerList((res) => {
// res.eventId === 'layerList'
console.log(res.tileLayers, res.dsLayers, res.markerLayers, res.lccLayers)
})
```
回包结构:
```json
{
"eventId": "layerList",
"ok": true,
"tileLayers": [
{ "id": "chemistry", "type": "3dtiles", "tag0": "tileLayers", "url": "...", "show": true }
],
"dsLayers": [],
"markerLayers": [
{ "id": "poi-001", "name": "东门", "type": "marker", "x": 106.58, "y": 38.19, "z": 1314, "show": true }
],
"lccLayers": [
{ "id": "lcc-tower", "name": "塔楼", "type": "lcc", "path": "D:/scans/meta.lcc", "show": true, "enableGeoPlace": true }
],
"lccCount": 1
}
```
### 3.7 太阳时 `setSolarTime`
```js
gis.setSolarTime(tm)
```
| 参数 | 说明 |
| ---- | ----------------------------------------------------------------- |
| `tm` | **HH.MM** 小数:点前为小时,点后为分钟(两位数语义)。如 `12.30` = 12:30,`18.45` = 18:45 |
引擎按 `Time_of_Day = tm × 100` 写入(例如 `12.30` → `1230.00`)。
```js
gis.setSolarTime(0) // 0:00
gis.setSolarTime(12.0) // 12:00
gis.setSolarTime(12.30) // 12:30
```
> UI 滑条若使用「小数小时」(`12.5` = 12:30),发送前需转为 HH.MM(`12.30`)。
### 3.8 天气 `setWeather`
```js
gis.setWeather(weatherId)
gis.setWeather(weatherId, extras)
gis.setWeather({ weatherId: 7, rain: 0.8, fog: 0.3 })
```
#### 预设 `weatherId`(与编辑器一致,**1–11**)
| Id | 含义 |
| --- | --- |
| 1 | 晴天 |
| 2 | 少云 |
| 3 | 多云 |
| 4 | 阴天 |
| 5 | 雾 |
| 6 | 小雨 |
| 7 | 雨 |
| 8 | 暴风雨 |
| 9 | 小雪 |
| 10 | 雪 |
| 11 | 暴风雪 |
#### 可选细节 / Weather Details(均可省略;有则覆盖预设)
**优先 `weatherId`(1–11)**:只传预设时切换天气类型,雨/雪等粒子更可靠。
**细节滑条**:不带 `weatherId` 时,按雨/雪/雾/云强度推断最接近的天气类型再切换。
| 字段 | 含义 | 建议范围 |
| ------------------------------------- | ------------------------------------------ | ----- |
| `rain` / `rainIntensity` | 雨量 | 0–1 |
| `snow` / `snowIntensity` | 雪量(与雨共用雨雪通道,偏雪时降温) | 0–1 |
| `fog` / `fogDensity` / `fogIntensity` | 雾强度(**0 = 场景完全无雾**) | 0–1 |
| `wind` / `windIntensity` | 风强度 | 0–1 |
| `windDirection` / `windDir` | 风向(度) | 0–360 |
| `cloudCoverage` / `cloud` | 云密度(体积云) | 0–1 |
| `thunder` / `lightning` | 闪电概率/频次(越大越密;并缩短 Lightning Flash Interval) | 0–1 |
| `transition` / `transitionSeconds` | 过渡秒数 | ≥0 |
```js
gis.setWeather(7)
gis.setWeather(7, {
rain: 0.8,
fog: 0.3,
wind: 0.5,
windDirection: 90,
cloudCoverage: 0.9,
thunder: 0.6
})
// 仅调细节(沿用当前缓存状态合并)
gis.setWeather({ rain: 0.5, cloudCoverage: 0.8, transition: 0.25 })
```
全局雾(推荐;引擎启动默认清雾):
```js
gis.setFog(0) // 彻底无雾(关闭高度雾 / 体积雾,并 r.Fog 0)
gis.clearFog() // 同 setFog(0)
gis.setFog(0.5)
gis.getFog((e) => console.log(e.fog))
gis.setWeather({ fog: 0 })
gis.setSunParam(1, 0) // intensity, fogDensity
```
Legacy:`setFog` / `clearFog` / `getFog`;RPC:`weather.setFog` / `weather.set`,params:`{ "fog": 0 }`。
### 3.9 环境光参数(部分)
```js
gis.getSunParam((e) => {
// intensity, fogDensity, timeOfDay(HH.MM), h, s, v, a
})
gis.setSunParam(intensity, fogDensity, h, s, v, a)
```
当前实现:`fogDensity` 写入雾相关状态;`intensity` 会粗映射云量;HSV 完整调色能力有限,建议以天气 + 太阳时为主。
### 3.10 鼠标漫游开关
```js
gis.enableMousePan(true) // 开
gis.enableMousePan(false) // 关
```
鼠标操作(引擎侧,**上帝视角**):
| 操作 | 行为 |
| ----- | -------------------------- |
| 左键拖 | 平移(拾取点粘滞) |
| 右键左右拖 | 绕拾取点水平旋转 |
| 右键上下拖 | 调节俯仰角 |
| 滚轮 | 朝拾取点缩放(灵敏度随对地高度增大,可缩小至地球级) |
### 3.10.1 相机模式 / FPS / 第三人称机器人
```js
// 第一人称:WASD 贴地行走,QE 升降,鼠标移动环视(无需按右键),Shift 加速
gis.setPlayMode('firstPerson')
// 第三人称机器人:场景中生成可操作角色(走/跑/跳,物理碰撞,跟随相机)
gis.setPlayMode('robot')
// 别名:character | tps | thirdPersonCharacter
// 回到上帝视角(默认 Cesium 漫游;legacy 别名 thirdPerson)
gis.setPlayMode('godView')
gis.setPlayMode('thirdPerson') // 兼容旧别名:仍表示上帝视角
// 自由飞行(WASD/QE 六自由度 + 左键平移 + 右键环视)
gis.setPlayMode('freeFly')
gis.getPlayMode((e) => console.log(e.mode)) // firstPerson | robot | thirdPerson | freeFly
```
| mode | 说明 |
| --------------------------------- | --------------------------------------------- |
| `thirdPerson` / `god` / `godView` | 上帝视角(默认;`thirdPerson` 为兼容别名) |
| `firstPerson` / `fps` / `fp` | 第一人称 FPS |
| `robot` / `character` / `tps` | 第三人称机器人(走/跑/跳 + 物理) |
| `drive` / `car` / `vehicle` | 开车(默认 SportsCar;`sportsCar` / `offroad` 指定车型) |
| `freeFly` / `fly` | 自由飞行 |
键盘快捷键(**主键盘数字键**,非小键盘):
| 键 | 模式 |
| --- | ------- |
| `1` | 上帝视角 |
| `2` | 第一人称 |
| `3` | 第三人称机器人 |
| `4` | 开车 |
| `5` | 自由飞行 |
#### 开车模式
```js
gis.setPlayMode('drive') // SportsCar
gis.setPlayMode('sportsCar')
gis.setPlayMode('offroad') // OffroadCar
```
操作:`W` 油门,`S` 刹车/倒车,`A/D` 转向,`空格` 手刹,`**C` 切换追尾/车头视角**,滚轮调追尾镜头距离。
默认使用内置载具资源。
机器人操作:`WASD` 走,`Shift` 跑,`空格` 跳,鼠标移动环视(无需按右键),滚轮调镜头距离。
RPC:`camera.setMode` / `camera.getMode`,params:`{ "mode": "robot" }`。
### 3.11 加载 Pak(叠加)
默认把 Pak 内关卡**叠加**到当前数字孪生场景,**不切主场景**,沿用本系统坐标、操作与天气。
```js
gis.loadPak(pakPathOrParams, mountPoint?, callback?)
```
| 参数 | 说明 |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pakPath` / `path` | Pak 路径(相对 `Saved/Paks` 或绝对路径) |
| `mountPoint` | 可选,空/`auto` = Pak 自带挂载点 |
| `id` | 可选,默认取文件名 |
| `map` | 可选,Pak 内关卡短名(如 `SHdemo_StaticExport_86708178`) |
| `mapPath` | **推荐**:关卡长包名(如 `/Game/ModelExport/Temp/SHdemo_StaticExport_86708178`)。资源包 `manifest.json` v2 的 `cookedMapPath` / `staticPak.mapPath` 即此字段,**优先于** `map`,勿用源关卡名(`MAP01`/`City02`) |
| `shaderLibrary` / `project` | 可选,Cook 工程名(如 `LFY0401`)。独立包改名后必须传,否则 ShaderArchive 打不开、材质发暗 |
| `longitude`/`latitude`/`height`/`yaw` | 可选,叠加原点经纬度;省略则用**当前相机**位置 |
| `mode` | `overlay`(默认)或 `travel`(切换进入 Pak 关卡) |
| `flyTo` | overlay 时是否把相机飞到叠加点附近(默认 true) |
| `callback` | `{ eventId:"loadPak", ok, overlay, streamedMap, longitude, latitude, ... }` |
```js
// 推荐:使用导出 manifest 记录的长包名(与源工程地图命名无关)
gis.loadPak({
pakPath: 'SHdemo-Windows.pak',
id: 'shdemo',
mapPath: '/Game/ModelExport/Temp/SHdemo_StaticExport_86708178',
shaderLibrary: 'SHdemo',
longitude: 121.6,
latitude: 31.13,
height: 5,
flyTo: false
})
// 兼容:仅短名(旧包 / 调试)
gis.loadPak({
pakPath: 'SHdemo-Windows.pak',
map: 'SHdemo_StaticExport_86708178',
id: 'shdemo'
})
```
> **资源包 manifest v2**:导出时写入 `cookedMapPath`、`staticPak.mapPath`、`mount.primaryMapPath` 与 `mount.maps[]`。导入/加载只认这些字段做 overlay,**不**根据 `MAP01`/`City02` 等源图习惯猜路径。
> 卸载:
```js
gis.unmountPak(id, callback?)
gis.listMountedPaks(callback?)
```
> 生产环境建议只把 Pak 放在 `PakRoot` 下,并关闭绝对路径允许。
>
> **叠加(推荐)** 需要同目录 `.pak` + `.utoc` + `.ucas`,且与当前宿主同平台:Windows 用 `*-Windows` 三件套,Linux / 麒麟云渲染用 `*-Linux` 三件套(须 **Shipping** Cook,与安装宿主一致)。有明确 `map` / `cookedMapPath` 时按该关卡加载,勿落到 `StaticExport` 空壳。
>
> **叠加(推荐)** 保持当前主场景。
>
> `mode:'travel'` 会切换进入 Pak 关卡(会离开当前主场景)。
>
> **禁止宿主同源包 overlay**:不要用从宿主工程导出的 Pak 对云渲染做 `loadPak` 叠加(易导致挂载冲突/崩溃)。请始终从**独立城市工程**导出。编辑器导入时会检测并跳过自动挂载。
>
> **楼层显隐(推荐整包)**:导出默认打成 **1 个 Base Pak**,对象带 `tag0/tag1/tag2`(如 `buildings` / `building-01` / `F01`)。运行时用 `showModels` / `hideModels` 控制显隐,**无需**按楼层拆多个 Pak。上述接口同时作用于运行时模型与已挂载 overlay Pak 内对象。
### 3.12 测量 `setMeasureMode`
```js
gis.setMeasureMode(mode, opts?)
gis.getMeasureCallback = (res) => { console.log(res.data) }
```
| mode | 说明 |
| ------------------------ | ----------------------------- |
| `Length` | 直线长度(世界坐标折线) |
| `Ground` / `GroundClamp` | 贴地长度(段内采样地形) |
| `Area` | 多边形面积(ENU 投影鞋带公式) |
| `Volume` | 棱柱体积 = 面积 × `height`(米,默认 10) |
| `Clear` | 清除测量图形与状态 |
交互:左键加点;右键单击结束;面积/体积也可在靠近起点约 5m 内再点一次闭合。测量结果推送 `eventId: getMeasureResult`。
```js
gis.getMeasureCallback = (p) => console.log(p.data) // { mode, value, unit, points, final, ... }
gis.setMeasureMode('Length')
gis.setMeasureMode('Volume', { height: 15 })
gis.setMeasureMode('Clear')
```
控制面 RPC:`measure.setMode` / `measure.finish` / `measure.clear` / `measure.getResult`。
### 3.12.1 绘制 Draw(折线 / 多边形 / GeoJSON)
持久几何(非测量临时图形)。交互与测量一致:**左键加点,右键结束**。坐标为经纬度,以 GeoJSON 写入场景 `components[]`。
**没有单独的 `loadGeoJson`**:把 GeoJSON 的 `LineString` / `Polygon` 放进 `geometry`,用 `drawAdd` / `drawUpdate` 下发即可。
```js
gis.drawAdd({
id: 'draw-1',
type: 'polyline', // | 'polygon'
name: '折线-1',
visible: true,
geometry: {
type: 'LineString', // Polygon 时 coordinates 为 [[[lng,lat,h],...]]
coordinates: [[106.58, 38.19, 0], [106.59, 38.19, 0]]
},
style: {
lineWidth: 2, // 米:水平线宽 / 多边形边框宽;折线拉伸成墙时为墙厚
lineColor: '#ffcc00',
lineOpacity: 1,
emissive: 0, // 0~5
metallic: 0,
roughness: 0.8,
extrudeHeight: 0, // 米:竖直三维拉伸;折线→墙高,多边形→体高(与贴地独立)
clampToGround: true, // 贴地:沿地形/瓦片表面采样(起伏地形推荐开启)
clampHeightOffset: 0.15, // 贴地后抬高(米),减轻与地面闪烁
clampSampleSpacing: 25, // 贴地段内采样间距(米);大面积可调到 50–100 提速
enableCollision: false, // 海量要素建议 false,减少碰撞网格开销
castShadow: false, // 海量建议 false
fillColor: '#3388ff', // 仅 polygon + fillMaterial=solid
fillOpacity: 0.35,
fillMaterial: 'solid' // solid | grass | moss | gravel | water | water_ocean | concrete | brick | …
}
})
gis.drawUpdate({ id: 'draw-1', /* 同 drawAdd 字段 */ })
gis.drawRemove('draw-1')
gis.drawSetVisible('draw-1', false)
gis.drawClear()
```
贴地多边形示例:
```js
gis.drawAdd({
id: 'area-1',
type: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [[[106.58, 38.19, 0], [106.59, 38.19, 0], [106.59, 38.20, 0], [106.58, 38.19, 0]]]
},
style: {
fillColor: '#3388ff',
fillOpacity: 0.35,
clampToGround: true,
clampHeightOffset: 0.15,
clampSampleSpacing: 40
}
})
```
| 字段 | 说明 |
|------|------|
| `clampToGround` | `true` 时贴合地表;别名 `clampToTerrain` / `stickToGround`;也可用 `altitudeMode: 'clampToGround'` |
| `clampHeightOffset` | 贴地后沿法线抬高(米),默认约 `0.15` |
| `clampSampleSpacing` | 段内采样间距(米),默认 `25`;越大越快越糙 |
| `enableCollision` | 是否生成拾取碰撞;海量 GeoJSON 建议 `false` |
| `castShadow` | 是否投射阴影;海量建议 `false` |
| `extrudeHeight` | 竖直挤出高度(米);可与贴地同时使用(先贴地再拉伸) |
`fillMaterial` 使用与运行时模型相同的材质预设 key(完整列表见 §3.17「模型材质」);`solid` 为纯色填充(配合 `fillColor` / `fillOpacity`)。
`lineWidth` 控制水平宽度(折线带宽、墙厚、多边形边框);`extrudeHeight` 控制竖直挤出;**真正贴地请用 `clampToGround`**(`extrudeHeight: 0` 只表示不拉伸,并不等于贴合起伏地形)。
贴地依赖场景中有可命中的地形 / 3D Tiles 等表面;若瓦片关闭物理网格,可能回退到椭球高度。
#### 导入 GeoJSON
编辑器「绘制 → 导入 GeoJSON」:选择 `.geojson`/`.json` 或粘贴文本。支持 `FeatureCollection` / `Feature` / Geometry(`LineString`/`MultiLineString`/`Polygon`/`MultiPolygon`,忽略 Point)。样式字段与上方 `style` 相同(含贴地),批量应用到全部要素;要素 `properties` 可覆盖单项(如 `fillMaterial`、`extrudeHeight`、`clampToGround`、`stroke`)。可设几何简化容差(经纬度度数,约 `0.00005`≈5m)以降低顶点量;海量导入会分批下发。
场景组件示例:
```json
{
"id": "draw-1",
"type": "polyline",
"name": "折线-1",
"visible": true,
"params": {
"geometry": { "type": "LineString", "coordinates": [[lng, lat, h], "..."] },
"style": {
"lineWidth": 2,
"lineColor": "#ffcc00",
"clampToGround": true,
"clampHeightOffset": 0.15,
"clampSampleSpacing": 25,
"enableCollision": false
}
}
}
```
控制面 / Legacy:`draw.add` / `drawAdd` / `draw.update` / `drawRemove` / `drawClear` / `drawSetVisible`;兼容旧名 `addGeoLine`。
### 3.13 热力图 Heatmap
热力图按地理坐标落点并烘焙高度纹理。开启 `stickToSurface` 时沿重力做复杂碰撞多段射线,**优先贴建筑 / 业务 3D Tiles / 模型表面**,无模型命中再回退地板 / 地形;贴合后会沿表面法线略抬高,减轻与模型穿插干涉。
```js
gis.addHeatmapGeo(lon, lat, value, height?) // isGeo=true
gis.addHeatmap(x, y, value, z?) // 引擎厘米坐标
gis.randomHeatmap({ x, y, z, count, radiusDeg, stickToSurface: true })
gis.setHeatmapStickToSurface(true) // 贴模型/瓦片表面(优先于地板)
gis.setHeatmapOpacity(0.7)
gis.setHeatmapVisible(true)
gis.clearHeatmap()
```
| 参数 | 说明 |
| --------------------------------- | ------------------------------------------------------------ |
| `x/y` 或 lon/lat | 经度 / 纬度(度) |
| `z` / `height` | 椭球高(米),化学场景约 `1314` |
| `v` / `value` | 强度 0–1 |
| `count` | 随机点数,默认 40 |
| `radiusDeg` | 随机半径(度),默认 `0.002` ≈ 200m |
| `stickToSurface` / `stickToModel` | `true` 时投影到碰撞表面;射线优先非地形模型/瓦片;基础资源包 overlay(如涡轮发动机)会自动开启可拾取碰撞 |
化学场景冒烟:
```js
gis.setViewGeo(106.585793, 38.191845, 2800, 0, -45, 0)
gis.load3dTiles('chemistry', 'https://iot.raytue.com/tiles/chemistry/tileset.json')
gis.randomHeatmap({ x: 106.585793, y: 38.191845, z: 1314, count: 40, stickToSurface: true })
```
控制面 RPC:`heatmap.add` / `heatmap.setPoints` / `heatmap.random` / `heatmap.setStickToSurface` / `heatmap.clear` / `heatmap.setOpacity` / `heatmap.setVisible`。
Legacy PS:`addHeatmap` / `clearHeatmap` / `randomHeatmap` / `setHeatmapStickToSurface` / `setHeatmapOpacity` / `setHeatmapVisible`。
### 3.14 风场 WindField(WIND 网格 / W3D 体数据)
引擎按文件 **magic** 分流:
| magic | 用途 | 可视化 |
| ------ | ----------------------- | -------------------------------------------------- |
| `WIND` | 经纬度网格 + 多层气压 + 多时次(台风等) | `mode` 互斥:speed / particles / streamlines / arrows |
| `W3D` | 局部米制体数据 UVW(风洞等) | **切面着色 + 切面箭头 + 3D 粒子**可叠加(对齐风洞查看器) |
#### 3.14.1 WIND(气象网格)
```js
gis.loadWindField({ id: 'typhoon', url: 'https://example.com/typhoon_wind.bin' })
gis.loadWindField({ id: 'typhoon', path: 'G:/data/typhoon_wind.bin' })
gis.setWindField({
id: 'typhoon',
frameIndex: 0,
levelIndex: 0,
mode: 'arrows', // speed | particles | streamlines | arrows
visible: true,
opacity: 0.85,
arrowStride: 1,
arrowLengthScale: 1.5,
arrowThicknessScale: 1.2,
arrowMinLength: 0,
arrowMaxLength: 0,
arrowMinSpeed: 0.25,
arrowColorMode: 'speed',
colorLow: '#2e5c7d',
colorHigh: '#e34a29',
liftMeters: -1,
particleSizeScale: 1,
particleSpeedScale: 1
})
gis.setWindFieldPlayback({ id: 'typhoon', play: true, speed: 1 })
gis.clearWindField('typhoon')
```
| 参数 | 说明 |
| ------------------------------------------ | ------------------------------------------------ |
| `id` | 风场实例 ID,默认 `default` |
| `url` / `path` | 二进制来源(二选一) |
| `frameIndex` / `levelIndex` / `timeAlpha` | 时次 / 气压层 / 插值 |
| `mode` | `speed` / `particles` / `streamlines` / `arrows` |
| `opacity` / `visible` | 不透明度与可见性 |
| `arrow*` / `color*` / `liftMeters` | 箭头与配色、贴地抬升 |
| `particleSizeScale` / `particleSpeedScale` | 粒子尺寸 / 平流倍率 |
| `play` / `speed` | 时间轴播放(`setWindFieldPlayback`) |
#### 3.14.2 W3D(体数据切面,对齐风洞查看器)
```js
gis.loadWindField({
id: 'tunnel',
url: '/designer/api/wind/car_uv_3d.bin',
longitude: 121.5,
latitude: 31.2,
height: 5,
yaw: 0,
sliceOri: 'side', // side | top | front (y= / z= / x=)
slicePos: 0, // 数据坐标米
showSlice: true,
showArrows: true,
showParticles: true,
particleCount: 15000, // 上限约 20000
speedScale: 0.25, // 粒子平流倍率
uRef: 40, // 归一化→物理风速
vMax: 1.6, // 色标归一化上限
opacity: 0.92,
visible: true
})
gis.setWindField({
id: 'tunnel',
sliceOri: 'top',
slicePos: 0.3,
showParticles: true,
particleCount: 12000,
speedScale: 0.4
})
```
| 参数 | 说明 |
| -------------------------------------------- | ---------------------------------- |
| `sliceOri` | `side`(y 恒定)/ `top`(z)/ `front`(x) |
| `slicePos` | 切片位置(米,数据坐标) |
| `showSlice` / `showArrows` / `showParticles` | 三层可同时开启 |
| `particleCount` | 3D 平流粒子数 |
| `speedScale` | 平流流速倍率(对应查看器 speedScale) |
| `uRef` / `vMax` | 参考风速与色标上限 |
| `arrowColorMode` / `arrowColor` | 箭头:`speed`(默认,按风速色标)或 `fixed` + 颜色 |
| `particleColorMode` / `particleColor` | 粒子:`speed`(默认)或 `fixed` + 颜色 |
| `longitude` / `latitude` / `height` / `yaw` | 将数据原点放到该地理点 |
W3D 头:`W3D`(3)+pad + ver(u8) + nx,ny,nz(u32) + origin(3×f64) + spacing(3×f64) + 2×f32;载荷 float32 UVW,体素序 z→y→x。
控制面 RPC:`windField.load` / `windField.set` / `windField.setPlayback` / `windField.clear`。
Legacy PS:`loadWindField` / `setWindField` / `setWindFieldPlayback` / `clearWindField`。
### 3.15 粒子特效 Effect(火焰 / 烟雾 / 喷水)
基于 StarterContent Cascade 粒子(`P_Fire` / `P_Smoke` / `P_Steam_Lit` 等),经 Cesium 地理落点。也可用 `systemPath` 覆盖为自定义 Cascade / Niagara 资产。
```js
// 统一入口
gis.addEffect({
id: 'fx-fire-1',
type: 'Fire', // Fire | Smoke | Spray | Steam | Sparks | Explosion | Custom
x: 106.585793, y: 38.191845, z: 1314, isGeo: true,
scale: 1.5, // 整体尺寸
radius: 1.2, // 水平扩散
heightScale: 1.8, // 垂直拉伸
intensity: 2.0, // 强度 / 密度 0–10
speed: 1.2, // 播放速率
opacity: 0.9,
color: [1, 0.45, 0.1, 1],
yaw: 0, pitch: 0, roll: 0,
velocity: [0, 0, 200], // 额外速度 / 风 (cm/s)
lifetimeScale: 1.0,
gravityScale: 1.0,
tag0: 'Alarm',
floatParams: { SpawnRate: 40 }, // 透传 Cascade/Niagara 参数名
// systemPath: '/Game/MyFX/NS_CustomFire.NS_CustomFire'
})
gis.addSmoke({ id: 'fx-smoke-1', x: 106.586, y: 38.192, z: 1314, scale: 2, intensity: 1.5 })
gis.addSpray({ id: 'fx-spray-1', x: 106.5855, y: 38.1915, z: 1314, pitch: -90, scale: 1.2, intensity: 2 })
gis.updateEffect({ id: 'fx-fire-1', intensity: 3, scale: 2 })
gis.setEffectVisible('fx-fire-1', false)
gis.setEffectVisible({ tag0: 'Alarm' }, true)
gis.removeEffect('fx-fire-1')
gis.clearEffects()
// Gizmo 编辑(与模型共用 enableGizmo):开启后单击粒子特效即可选中并拖轴
gis.enableGizmo(true)
gis.selectEffect('fx-fire-1') // 也可 API 选中
gis.setGizmoMode('translate') // translate|rotate|scale
// 兼容旧接口
gis.addModel({ id: 'fire-001', type: 'Fire', x: 106.585793, y: 38.191845, z: 1314, scale: 1 })
gis.addFire({ fires: [{ id: 'f1', position: [106.585793, 38.191845, 1314], intensity: 2 }] })
gis.removeFire('f1')
```
| 参数 | 说明 |
| ---------------------------------- | -------------------------------------------------------------------------------------------- |
| `type` | `Fire` 火焰 / `Smoke` 烟雾 / `Spray`|`Water` 喷水 / `Steam` 蒸汽 / `Sparks` / `Explosion` / `Custom` |
| `x,y,z` 或 `position[]` | 默认地理经纬高;`isGeo:false` 时为引擎厘米坐标 |
| `scale` / `radius` / `heightScale` | 尺寸与纵横比 |
| `intensity` | 强度(影响密度观感) |
| `speed` | 播放快慢(`CustomTimeDilation`) |
| `opacity` / `color` | 透明度与着色(模板支持时生效) |
| `yaw/pitch/roll` | 发射朝向(喷水默认仰角 -90°) |
| `velocity` / `wind` | 附加速度向量 |
| `lifetimeScale` / `gravityScale` | 寿命 / 重力偏置 |
| `systemPath` | 覆盖粒子资产路径(Cascade 或 Niagara) |
| `floatParams` / `vectorParams` | 额外命名参数字典 |
| `tag0..2` | 批量显隐标签 |
| 选中 / Gizmo | `enableGizmo(true)` 后单击特效;`selectEffect(id)`;与模型全局仅一个选中 |
控制面 RPC:`effect.add` / `effect.update` / `effect.remove` / `effect.clear` / `effect.setVisible` / `effect.list` / `effect.select`。
Legacy PS:`addEffect` / `addFire` / `addSmoke` / `addSpray` / `updateEffect` / `removeEffect` / `clearEffects` / `setEffectVisible` / `listEffects` / `selectEffect`;`addModel({type:'Fire'|'Smoke'|'Spray'|…})` 同样走特效。
### 3.16 场景灯光 Light(点光 / 聚光 / 面光 / 方向光)
运行时创建场景灯光。资源包导出时会把关卡内灯光参数写入 `manifest.lights`;导入/加载场景时再通过本 API 还原。
```js
gis.addLight({
id: 'light-001',
type: 'point', // point | spot | rect | directional
x: 106.585793, y: 38.191845, z: 1314, isGeo: true,
intensity: 3000, // point/spot=candela;rect=nits;directional=lux
attenuationRadius: 2000,
color: [1, 0.95, 0.85, 1],
yaw: 0, pitch: -45, roll: 0,
castShadows: true,
// spot:
// innerConeAngle: 20, outerConeAngle: 40,
// rect:
// sourceWidth: 200, sourceHeight: 200,
tag0: 'Lights', tag1: 'F1',
visible: true
})
gis.updateLight({ id: 'light-001', intensity: 5000, color: [1, 0.8, 0.6, 1] })
gis.setLightVisible('light-001', false)
gis.setLightVisible({ tag0: 'Lights' }, true)
gis.selectLight('light-001') // Gizmo 选中
gis.flyToLight('light-001', { distance: 40, duration: 1.2 })
gis.removeLight('light-001')
gis.clearLights()
gis.listLights()
```
| 参数 | 说明 |
| ----------------------------------- | ------------------------------------------------------------- |
| `type` | `point` 点光 / `spot` 聚光 / `rect`|`area` 面光 / `directional` 方向光 |
| `x,y,z` 或 `position[]` | 默认地理经纬高;`isGeo:false` 时为引擎厘米坐标 |
| `intensity` | 强度(单位随类型变化) |
| `attenuationRadius` | 衰减半径(点/聚/面) |
| `color` | 线性 RGB(A) 数组 |
| `innerConeAngle` / `outerConeAngle` | 聚光锥角(度) |
| `sourceWidth` / `sourceHeight` | 面光源尺寸 |
| `yaw/pitch/roll` | 朝向 |
| `castShadows` | 是否投射阴影 |
| `tag0..2` | 批量显隐标签 |
| 选中 / Gizmo | `enableGizmo(true)` 后单击灯光;`selectLight(id)` |
控制面 RPC:`light.add` / `light.update` / `light.remove` / `light.clear` / `light.list` / `light.select` / `light.setVisible` / `light.flyTo`。
Legacy PS:`addLight` / `addCustomLight` / `updateLight` / `removeLight` / `clearLights` / `setLightVisible` / `showLights` / `hideLights` / `listLights` / `selectLight` / `flyToLight` / `focusLight`。
### 3.17 运行时模型 Model(GLB / FBX / OBJ / Datasmith)
```js
// 自定义文件模型(Legacy: loadDSModel)
gis.addCustomModel({
id: 'model-01',
type: 'glb', // glb|gltf|fbx|obj|ds|datasmith|Pole1|Car1|…
path: 'C:/models/tank.glb', // 或 Saved/Paks 相对路径、/Game/... 资产
x: 106.585793, y: 38.191845, z: 1314, // 默认按地理;可加 isGeo:false 用引擎厘米坐标
heading: 0, pitch: 0, roll: 0, // HPR;heading 与 yaw 同义(亦可写 yaw)
scale: 1,
tag0: 'Devices', tag1: 'F1',
material: 'concrete' // 可选材质预设,见下方「模型材质」;亦可用 materialPreset / fillMaterial
})
gis.removeModel('model-01')
gis.getTransform('model-01', (t) => console.log(t.location, t.rotation, t.scale))
gis.setTransform('model-01', { x, y, z }, { roll, pitch, yaw }, { x:1, y:1, z:1 })
// 骨骼编辑(模型需含骨骼网格):列出骨骼 → 选中骨骼显示 Gizmo → 读写骨骼变换
gis.listBones('model-01', (r) => console.log(r.bones))
gis.selectBone('model-01', 'spine_01') // 空字符串退出骨骼模式,回到模型 Gizmo
gis.getBoneTransform('model-01', 'spine_01', (t) => console.log(t.location, t.rotation))
gis.setBoneTransform('model-01', 'spine_01', {
location: { x: 0, y: 0, z: 10 },
rotation: { yaw: 15, pitch: 0, roll: 0 },
scale: { x: 1, y: 1, z: 1 }
})
// 节点/轴编辑(刚性层级,如工业机械臂 Axis1–Axis6):选中节点后用 Gizmo 旋转,子节点跟随
// 说明:glTF/GLB 需带节点层级;打开场景后若列表为空,请重新加载该模型
gis.listNodes('model-01', (r) => console.log(r.nodes))
gis.selectNode('model-01', 'Axis1') // 空字符串退出节点模式,回到模型 Gizmo
gis.getNodeTransform('model-01', 'Axis1', (t) => console.log(t.location, t.rotation))
gis.setNodeTransform('model-01', 'Axis1', {
location: { x: 0, y: 0, z: 0 },
rotation: { yaw: 30, pitch: 0, roll: 0 },
scale: { x: 1, y: 1, z: 1 }
})
// Gizmo 编辑:开启后单击场景模型或粒子特效选中(全局仅一个选中并显示 Gizmo);拖轴编辑;W/E/R = 移动/旋转/缩放
gis.enableGizmo(true)
gis.setGizmoMode('translate') // translate|rotate|scale
gis.selectModel('model-01') // 也可 API 选中模型
gis.selectEffect('fx-fire-1') // 也可 API 选中特效
gis.enableGizmo(false)
// 重力 / 质量 / 阴影(按模型 id)
gis.setModelGravity('model-01', true) // 开启物理+重力(编辑 Gizmo 时会临时挂起)
gis.setModelMass('model-01', 25) // kg
gis.setModelCastShadow('model-01', true) // 产生阴影
gis.setModelReceiveShadow('model-01', false) // 接收阴影(关=退出默认光照通道 0)
gis.setModelPhysics({ id:'model-01', gravity:false, mass:10, castShadow:true, receiveShadow:true })
```
#### 模型材质(预设)
对已加载的运行时模型应用内置表面材质预设(与编辑器模型面板材质球一致)。绘制面 `style.fillMaterial` 使用同一套预设 key(见 §3.12.1)。
```js
// 列出当前引擎可加载的材质预设(仅返回实际可用项)
gis.listModelMaterials((res) => {
console.log(res.count, res.materials)
// materials[]: { value, label, color } — color 为 UI 色板 #RRGGBB
})
// 切换模型材质(material / fillMaterial / materialPreset 同义)
gis.setModelMaterial('model-01', 'brick', (res) => {
console.log(res.id, res.material)
})
// 获取预设预览图(data URL,可用于前端缩略图)
gis.getMaterialPreview('glass', (res) => {
img.src = res.preview // data:image/png;base64,...
})
// 当前选中模型的材质(需先选中)
gis.getSelectedModel((res) => {
console.log(res.id, res.material || res.materialPreset)
})
```
| 预设 `value` | 说明(`label`) |
| ---------------------- | ------------ |
| `solid` | 默认基础色(无贴图风格) |
| `concrete` | 水泥 |
| `concrete_tiles` | 水泥砖 |
| `concrete_panels` | 水泥板 |
| `brick` | 红砖 |
| `cobble` | 鹅卵石 |
| `wood` | 橡木 |
| `wood_pine` | 松木 |
| `metal` | 钢材 |
| `glass` | 玻璃 |
| `grass` | 草地 |
| `moss` | 苔藓 |
| `gravel` | 碎石 |
| `sandstone` | 砂岩 |
| `slate` | 石板 |
| `water` / `water_lake` | 湖面 |
| `water_ocean` | 海面 |
| `floor` | 地板 |
说明:`listModelMaterials` 只返回当前打包内容中实际可加载的贴图预设;`solid` 为内置默认基础色,一般不出现在列表中,但仍可通过 `setModelMaterial` / `material:'solid'` 使用。个别预设若资源未打进包会从列表中省略。`material` / `fillMaterial` / `materialPreset` 字段不区分大小写。
| 能力 | 说明 |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 导入 | glb/gltf 优先 Interchange;ds/datasmith 用 DatasmithRuntime;fbx/obj 尝试 Interchange→Datasmith;失败则占位网格 |
| 内置 type | `Pole1`/`Car1`/`Tree1`/`Gate` 等无 path 时生成占位体 |
| 选中 | `enableGizmo(true)` 后单击模型或粒子特效选中;场景同时只有一个选中对象显示 Gizmo;骨骼模型可用 `listBones` / `selectBone`;刚性层级可用 `listNodes` / `selectNode` 进入节点/轴编辑 |
| 物理/阴影 | `setModelGravity` / `setModelMass(kg)` / `setModelCastShadow` / `setModelReceiveShadow` / `setModelPhysics` |
| 材质 | `listModelMaterials` / `setModelMaterial(id, material)` / `getMaterialPreview(value)`;`addCustomModel` 可带 `material`;`getSelectedModel` 回传当前材质 |
| 图层 | `getLayerList` 的 `dsLayers` 列出已添加模型 |
| 显隐 | `showModels(tag0, tag1, tag2)` / `hideModels(...)`:按导出 tag 显隐;含已挂载 Pak overlay 内对象(整包楼层切换) |
| RPC | `model.add` / `model.remove` / `model.list` / `model.getTransform` / `model.setTransform` / `model.listBones` / `model.getBoneTransform` / `model.setBoneTransform` / `model.selectBone` / `model.listNodes` / `model.getNodeTransform` / `model.setNodeTransform` / `model.selectNode` / `model.enableGizmo` / `model.setGizmoMode` / `model.select` / `effect.select` / `model.setGravity` / `model.setMass` / `model.setCastShadow` / `model.setReceiveShadow` / `model.setPhysics` / `model.setMaterial`;材质列表与预览:`listModelMaterials` / `getMaterialPreview`;选中查询:`getSelectedModel` / `model.getSelected` |
```js
// 只显示某层(tag 与导出一致;null/* 表示通配)
gis.hideModels('buildings', 'building-01', null) // 先藏整栋
gis.showModels('buildings', 'building-01', 'F01')
```
### 3.18 车位 Parking
贴地车位模型(非广告牌/Marker):先将 4 行文字内容烘焙到底图,再以**一张图**贴地显示。`yaw` / `scaleX` / `scaleY` 作用于整张烘焙图。
绘图区为标准车位框(约 6.0m×2.5m):前 0.5m 为车位号区,后 5.5m 为车辆图(贴边框内填充、整体偏后)。
边框可选参数(均可省略,默认显示白色约 4px 边框):
- `showBorder`:是否显示边框(默认 `true`)
- `borderWidth`:边框宽度,烘焙像素(默认 `4`)
- `borderColor`:边框颜色,如 `'#ffffffff'` / `'#ffcc00'`
默认文字布局(车头/车位入口在图下方):
1. **车位号** — 前 0.5m 区域
2. **车牌** — 引擎盖靠车头;≥8 位绿牌,否则蓝牌
3. **进场时间** — 引擎盖中部,小字,`yyyy-MM-dd HH:mm:ss`
4. **停车时长(备用)** — 引擎盖靠挡风玻璃,大字琥珀色
`plate` 为空 ⇒ 空车位:只显示车位边框(若开启)+ 车位号。
默认底图 `icons/parking/default.png`。
```js
gis.addParking({
id: 'park-A01',
x: 106.585793, y: 38.191845, z: 1314,
icon: 'parking/default.png',
spaceNo: 'A-01',
plate: '粤AD12345',
entryTime: '2026-08-03 09:12:30',
spare: '2.5h',
scaleX: 1.2,
scaleY: 1.0,
yaw: 45,
showBorder: true,
borderWidth: 4,
borderColor: '#ffffffff'
})
gis.mouseClickCallback = (e) => {
gis.addParking({ id: 'park-' + Date.now(), x: e.x, y: e.y, z: e.z, spaceNo: 'A-01' })
}
gis.updateParking({ id: 'park-A01', plate: '', showBorder: true, borderColor: '#ffcc00ff', borderWidth: 6 })
gis.setParking({ parks: [{ id: 'park-A01', pNo: 'A-01', carNo: '粤A12345', startTime: '09:00', endTime: '2h' }] })
gis.removeParking('park-A01')
gis.clearParkings()
gis.showParking() / gis.hideParking()
// 放置预览:车位图贴地跟随鼠标(与正式车位同尺寸 6.0m×2.5m × scale)
gis.showParkingPlacePreview({
icon: 'parking/default.png',
scaleX: 1,
scaleY: 1,
yaw: 45,
zOffset: 0.2, // 米,落点高度 = 拾取点 + zOffset(与编辑器一致)
showBorder: true,
borderWidth: 4,
borderColor: '#ffffffff'
})
gis.updateParkingPlacePreview({ yaw: 90, scaleX: 1.2 }) // 拾取过程中改朝向/缩放
gis.hideParkingPlacePreview()
// 别名(等价)
// gis.beginParkingPlacePreview(...) / gis.endParkingPlacePreview()
```
放置预览说明:
- `showParkingPlacePreview`:显示跟随光标的车位贴图(无车牌/文字,便于对准落点)
- `updateParkingPlacePreview`:更新预览参数(不必先 hide 再 show)
- `hideParkingPlacePreview`:隐藏并销毁预览
- 连续添加场景:开始拾取时 `show`,结束拾取时 `hide`;左键落点仍用 `addParking`
`getLayerList` / `layer.list` 额外返回 `parkingLayers`(及 `parkingCount`),项含 `id/spaceNo/plate/entryTime/spare/icon/yaw/scale/x/y/z/show` 等。
底图资源:`icons/parking/default.png`(默认;可自定义路径,勿再生成多色车图)。
---
## 4. 调用约定
### 4.1 对象入口
| 入口 | 场景 |
| ----------------------------- | -------------- |
| `window.$gisObj` | 播放页 / iframe 内 |
| `postMessage({ type, data })` | 父页面 → iframe |
| `$onGisReady` | 就绪钩子 |
### 4.2 未实现(noop)举例
`loadWMTS` / `loadWMS`、截图、楼栋楼层、电梯、`playRouteWithObject`(物体沿路径)、工具栏等——前端可继续调用,引擎回 `noop`,待后续版本。
### 4.3 地形全局水面(`loadGeoWaterLayer`)
基于 Cesium terrain 服务(支持 **quantized-mesh** 的 `layer.json`,或普通地形 tileset URL)铺全局水面材质:
```js
gis.loadGeoWaterLayer({
layerId: 'hk-water',
terrainUrl: 'http://127.0.0.1:88/basemaps/HK_Terrain/layer.json',
material: 'water_ocean', // 或 water(湖面)
waterColor: '#041F38', // 海面颜色(#RRGGBB,或 [r,g,b] / [r,g,b,a],0–1 或 0–255)
waterOpacity: 0.88, // 不透明度 0.05–1(海面默认 0.88,湖面约 0.78;过低会透视海底)
waveAmplitude: 2.0, // 浪强(海面默认 2.0,湖面默认 1.2;建议 0.8–2.5,上限 4;过大易见底)
waveFrequency: 0.6, // 浪密度(越大波纹越密;海面默认 0.6,湖面默认 1.0;建议 0.5–2)
waveSpeed: 1.35, // 流速(海面默认 1.35,湖面默认 0.7;建议 0–4)
foamIntensity: 1.0, // 岸线/船体交界白沫 0–1(材质泡沫带,非粒子溅花;依赖地形水面掩膜岸线,或船体/码头等实体模型)
waterRoughness: 0.08, // 粗糙度 0–1
waterSpecularity: 0.85, // 高光 0–1
x: 0, y: 0, z: 0 // 高度偏移(米)
})
```
场景图层类型为 `geo-water` 时,打开场景会自动调用上述接口;图层属性可改颜色与波浪参数,保存后重载生效。本地 terrain 与 3D Tiles / GT 一样走 §3.5 的 `/basemaps` 托管(`RTENGINE_BASEMAPS_ROOT`)。
---
## 5. 快速冒烟示例
```js
window.$onGisReady = () => {
const gis = window.$gisObj
gis.setViewGeo(106.585793, 38.191845, 2800, 0, -45, 0)
gis.setSolarTime(12.30)
gis.setWeather(3, { cloudCoverage: 0.7, wind: 0.2 })
gis.addMarker({
id: 'gate',
name: '东门',
x: 106.585793,
y: 38.191845,
z: 1314
})
gis.load3dTiles(
'chemistry',
'https://iot.raytue.com/tiles/chemistry/tileset.json',
16
)
gis.getLayerList((list) => console.log(list))
gis.mouseClickCallback = (e) => console.log('click', e)
}
```
---
Set sh = CreateObject("WScript.Shell")
sh.Run "cmd /c taskkill /F /IM JhLabGateway.exe >nul 2>nul & taskkill /F /IM node.exe >nul 2>nul & taskkill /F /IM python.exe >nul 2>nul", 0, True
WScript.Sleep 2000
sh.CurrentDirectory = "D:\apps\jh-lab\modbus_sim"
sh.Run """D:\apps\jh-lab\python\python.exe"" -u ""D:\apps\jh-lab\modbus_sim\simulate.py"" --csv-root ""D:\apps\jh-lab\modbus_sim\protocol"" --host 0.0.0.0 --port-102 1502 --port-103 1503 --port-104 1504", 0, False
WScript.Sleep 2500
sh.CurrentDirectory = "D:\apps\jh-lab\gateway"
sh.Run """D:\apps\jh-lab\gateway\JhLabGateway.exe"" config.json", 0, False
WScript.Sleep 2500
sh.CurrentDirectory = "D:\apps\jh-lab\platform\server"
sh.Run "cmd /c ""D:\apps\jh-lab\platform\server\start.bat""", 0, False
WScript.Sleep 5000
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\scripts\boot_all.log'
function L($m){ $line = ("[{0}] {1}" -f (Get-Date -Format o), $m); Add-Content -Path $log -Value $line; Write-Host $line }
Remove-Item $log -ErrorAction SilentlyContinue
L 'begin'
# stop
taskkill /F /IM JhLabGateway.exe 2>$null | Out-Null
taskkill /F /IM node.exe 2>$null | Out-Null
Get-Process python -ErrorAction SilentlyContinue | ForEach-Object {
try {
$cl = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $_.Id)).CommandLine
if ($cl -match 'simulate\.py') { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue; L ("kill sim " + $_.Id) }
} catch {}
}
Start-Sleep -Seconds 2
# sim
L 'start sim'
Start-Process -FilePath 'D:\apps\jh-lab\python\python.exe' `
-ArgumentList '-u','D:\apps\jh-lab\modbus_sim\simulate.py','--csv-root','D:\apps\jh-lab\modbus_sim\protocol','--host','0.0.0.0','--port-102','1502','--port-103','1503','--port-104','1504' `
-WorkingDirectory 'D:\apps\jh-lab\modbus_sim' -WindowStyle Hidden
Start-Sleep -Seconds 2
# gateway
L 'start gateway'
Start-Process -FilePath 'D:\apps\jh-lab\gateway\JhLabGateway.exe' -ArgumentList 'config.json' `
-WorkingDirectory 'D:\apps\jh-lab\gateway' -WindowStyle Hidden
Start-Sleep -Seconds 2
# node via bat (env vars)
L 'start node'
if (Test-Path 'D:\apps\jh-lab\platform\server\boot.log') { Remove-Item 'D:\apps\jh-lab\platform\server\boot.log' -Force }
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','D:\apps\jh-lab\platform\server\run_node.bat' `
-WorkingDirectory 'D:\apps\jh-lab\platform\server' -WindowStyle Hidden
Start-Sleep -Seconds 6
L 'processes'
Get-Process python,JhLabGateway,node -ErrorAction SilentlyContinue | ForEach-Object { L ($_.ProcessName + ' ' + $_.Id) }
L 'ports'
netstat -ano | Select-String 'LISTENING' | Select-String '1502|1503|1504|38080|3001' | ForEach-Object { L $_.Line.Trim() }
L 'health'
try { L ('gw ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:38080/health -TimeoutSec 5).Content) } catch { L ('gw FAIL ' + $_.Exception.Message) }
try { L ('api ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3001/api/settings -TimeoutSec 5).Content) } catch { L ('api FAIL ' + $_.Exception.Message) }
if (Test-Path 'D:\apps\jh-lab\platform\server\boot.log') {
L 'node boot.log tail:'
Get-Content 'D:\apps\jh-lab\platform\server\boot.log' -Tail 50 | ForEach-Object { L $_ }
}
L 'done'
$ErrorActionPreference = 'Continue'
$root = 'D:\apps\jh-lab'
Write-Host '=== firewall ==='
netsh advfirewall firewall delete rule name='JhLabGateway 38080' | Out-Null
netsh advfirewall firewall add rule name='JhLabGateway 38080' dir=in action=allow protocol=TCP localport=38080 | Out-Null
netsh advfirewall firewall add rule name='JhLab Modbus Sim' dir=in action=allow protocol=TCP localport=1502-1504 | Out-Null
Write-Host '=== .env ==='
$envFile = Join-Path $root '.env'
$txt = Get-Content -Raw -Encoding UTF8 $envFile
$txt = [regex]::Replace($txt, 'GATEWAY_URL=.*', 'GATEWAY_URL=http://127.0.0.1:38080')
Set-Content -Path $envFile -Value $txt -Encoding UTF8
Copy-Item -Force $envFile (Join-Path $root 'platform\server\.env')
Write-Host ((Select-String -Path $envFile -Pattern 'GATEWAY_URL').Line)
Write-Host '=== gateway config hosts -> 110 sim ==='
$cfgPath = Join-Path $root 'gateway\config.json'
$cfg = Get-Content -Raw -Encoding UTF8 $cfgPath | ConvertFrom-Json
foreach ($h in $cfg.modbusHosts) {
$h.host = '192.168.32.110'
if ($h.id -eq 'mb-102') { $h.port = 1502 }
if ($h.id -eq 'mb-103') { $h.port = 1503 }
if ($h.id -eq 'mb-104') { $h.port = 1504 }
$h.enabled = $true
}
$cfg.platformUrl = 'http://127.0.0.1:3001'
$cfg | ConvertTo-Json -Depth 8 | Set-Content -Path $cfgPath -Encoding UTF8
Write-Host '=== restart modbus sim ==='
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object {
if ($_.CommandLine -match 'simulate\.py') {
Write-Host ("kill sim " + $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$py = Join-Path $root 'python\python.exe'
$simArgs = @(
'-u', (Join-Path $root 'modbus_sim\simulate.py'),
'--csv-root', (Join-Path $root 'modbus_sim\protocol'),
'--host', '0.0.0.0',
'--port-102', '1502', '--port-103', '1503', '--port-104', '1504'
)
Start-Process -FilePath $py -ArgumentList $simArgs -WorkingDirectory (Join-Path $root 'modbus_sim') -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Host '=== restart gateway ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
$gwDir = Join-Path $root 'gateway'
Start-Process -FilePath (Join-Path $gwDir 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gwDir -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Host '=== restart node (pick new GATEWAY_URL) ==='
Get-CimInstance Win32_Process -Filter "Name='node.exe'" | ForEach-Object {
if ($_.CommandLine -match 'dist\\index\.js|dist/index\.js') {
Write-Host ("kill node " + $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$serverDir = Join-Path $root 'platform\server'
$node = Join-Path $root 'platform\node\node.exe'
Start-Process -FilePath $node -ArgumentList 'dist\index.js' -WorkingDirectory $serverDir -WindowStyle Hidden
Start-Sleep -Seconds 3
Write-Host '=== health ==='
try {
Write-Host ('gw: ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:38080/health -TimeoutSec 5).Content)
} catch { Write-Host ('gw fail: ' + $_.Exception.Message) }
try {
Write-Host ('api: ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3001/api/settings -TimeoutSec 5).Content)
} catch { Write-Host ('api fail: ' + $_.Exception.Message) }
netstat -ano | findstr ':1502 :1503 :1504 :38080 :3001'
$ErrorActionPreference = 'Continue'
$root = 'D:\apps\jh-lab'
# --- .env GATEWAY_URL ---
$envFile = Join-Path $root '.env'
if (Test-Path $envFile) {
$txt = Get-Content -Raw -Encoding UTF8 $envFile
if ($txt -match 'GATEWAY_URL=') {
$txt = [regex]::Replace($txt, 'GATEWAY_URL=.*', 'GATEWAY_URL=http://127.0.0.1:38080')
} else {
$txt = $txt.TrimEnd() + "`r`nGATEWAY_URL=http://127.0.0.1:38080`r`n"
}
Set-Content -Path $envFile -Value $txt -Encoding UTF8
Write-Host "patched .env GATEWAY_URL"
}
# --- restart modbus sim ---
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object {
if ($_.CommandLine -and $_.CommandLine -match 'simulate\.py') {
Write-Host "kill sim pid=$($_.ProcessId)"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$py = Join-Path $root 'python\python.exe'
$sim = Join-Path $root 'modbus_sim\simulate.py'
$args = @(
'-u', $sim,
'--csv-root', (Join-Path $root 'modbus_sim\protocol'),
'--host', '0.0.0.0',
'--port-102', '1502',
'--port-103', '1503',
'--port-104', '1504'
)
Start-Process -FilePath $py -ArgumentList $args -WorkingDirectory (Join-Path $root 'modbus_sim') -WindowStyle Hidden
Write-Host 'started modbus sim'
Start-Sleep -Seconds 2
# --- restart gateway ---
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
$gwDir = Join-Path $root 'gateway'
Start-Process -FilePath (Join-Path $gwDir 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gwDir -WindowStyle Hidden
Write-Host 'started JhLabGateway'
Start-Sleep -Seconds 2
try {
$h = Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:38080/health' -TimeoutSec 5
Write-Host "health: $($h.Content)"
} catch {
Write-Host "health fail: $($_.Exception.Message)"
}
netstat -ano | findstr ":1502 :1503 :1504 :38080"
......@@ -609,7 +609,8 @@ void pollOnce() {
for (const auto& p : mine) {
PointValue v;
v.ts = ts;
if (!useBuiltin) {
bool fillBuiltin = useBuiltin;
if (!fillBuiltin) {
const int n = wordCountForType(p.dataType);
std::vector<uint16_t> regs(static_cast<size_t>(n), 0);
bool complete = true;
......@@ -622,8 +623,9 @@ void pollOnce() {
regs[static_cast<size_t>(i)] = it->second;
}
if (complete) decodePoint(p, regs, 0, v, ts);
else v.quality = "bad";
} else {
else fillBuiltin = true; // 单点 Holding 缺字时改走内置公式,避免整表大量 bad
}
if (fillBuiltin) {
const std::string n = p.name + p.itemId;
double base = 10.0 + (p.address % 100) * 0.1;
if (n.find("冰箱") != std::string::npos || n.find("冷") != std::string::npos) {
......@@ -1046,10 +1048,23 @@ int main(int argc, char** argv) {
bool ok = false;
std::string detail;
if (localhost) {
// 本机联调:接受写入并记入值缓存,便于 UI 回读
// 无论是否 localhost,优先真实 Modbus 写(本机 Python 模拟器也听 127.0.0.1)
if (isCoil) {
ok = modbus_tcp::writeSingleCoil(host.host, host.port, point.unitId, point.address, value != 0);
detail = ok ? "FC05 ok" : "FC05 failed";
} else {
double scale = point.scale != 0 ? point.scale : 1.0;
double raw = (value - point.bias) / scale;
auto regVal = static_cast<uint16_t>(static_cast<int32_t>(std::lround(raw)) & 0xFFFF);
ok = modbus_tcp::writeSingleRegister(host.host, host.port, point.unitId, point.address, regVal);
detail = ok ? "FC06 ok" : "FC06 failed";
}
if (!ok && localhost) {
// 本机联调兜底:接受写入并记入值缓存,便于 UI 回读
ok = true;
detail = "accepted (builtin sim)";
}
if (ok) {
PointValue v;
v.eng = value;
v.raw = (point.scale != 0) ? (value - point.bias) / point.scale : value;
......@@ -1057,15 +1072,6 @@ int main(int argc, char** argv) {
v.ts = isoNow();
std::lock_guard<std::mutex> lock(g.mu);
g.values[point.id.empty() ? point.itemId : point.id] = v;
} else if (isCoil) {
ok = modbus_tcp::writeSingleCoil(host.host, host.port, point.unitId, point.address, value != 0);
detail = ok ? "FC05 ok" : "FC05 failed";
} else {
double scale = point.scale != 0 ? point.scale : 1.0;
double raw = (value - point.bias) / scale;
auto regVal = static_cast<uint16_t>(static_cast<int32_t>(std::lround(raw)) & 0xFFFF);
ok = modbus_tcp::writeSingleRegister(host.host, host.port, point.unitId, point.address, regVal);
detail = ok ? "FC06 ok" : "FC06 failed";
}
std::ostringstream o;
......
......@@ -906,8 +906,17 @@ export async function getBundleForGateway(): Promise<{
return {
pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
// 下发全量启用点(含写控);网关侧按 rw/regType 决定读或写
points: points.filter((p) => p.enabled),
// 下发全量启用点;按业务规则校正 rw(CSV 常把电表/实时温湿度误标 -F6)
// 网关侧仅看 rw:w 不参与周期读,校正后才能采到计量/传感点
points: points
.filter((p) => p.enabled)
.map((p) => ({
...p,
rw: inferPointRw(p.regType, (p.rw as 'r' | 'w' | 'rw') || 'r', {
name: p.name,
itemId: p.itemId,
}),
})),
}
}
......
......@@ -134,18 +134,42 @@ export async function syncPointsToGateway(): Promise<{
export async function gatewayModbusWrite(body: unknown): Promise<{ ok: boolean; message: string }> {
const base = resolveGatewayBase()
if (!base) return { ok: false, message: '未配置 GATEWAY_URL' }
try {
// mock / 未配网关:本地点位写成功(由调用方 applyPointTelemetry 落库)
if (!base) {
if (config.dataMode === 'mock') {
return { ok: true, message: 'mock-local' }
}
return { ok: false, message: '未配置 GATEWAY_URL' }
}
async function once(): Promise<{ ok: boolean; message: string }> {
const res = await fetch(`${base}/modbus/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Connection: 'close' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
// 避免 keep-alive 脏连接导致写超时;网关本机写通常 <100ms
signal: AbortSignal.timeout(8000),
})
const raw = (await res.json().catch(() => ({}))) as { message?: string }
if (!res.ok) return { ok: false, message: raw.message || `HTTP ${res.status}` }
return { ok: true, message: 'ok' }
return { ok: true, message: raw.message || 'ok' }
}
try {
return await once()
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : '写失败' }
// 网关刚拉起 / Docker NAT 抖动时重试一次
try {
await new Promise((r) => setTimeout(r, 300))
return await once()
} catch (e2) {
if (config.dataMode === 'mock') {
return {
ok: true,
message: `mock-local(网关写失败已旁路:${e2 instanceof Error ? e2.message : '写失败'})`,
}
}
return { ok: false, message: e2 instanceof Error ? e2.message : '写失败' }
}
}
}
import http from 'node:http'
import dns from 'node:dns'
import express from 'express'
import cors from 'cors'
import { WebSocketServer } from 'ws'
import { config } from './config.js'
/** Docker Desktop 上 host.docker.internal 常先解析到 IPv6,网关只听 IPv4 → fetch failed */
try {
dns.setDefaultResultOrder('ipv4first')
} catch {
/* Node < 17 */
}
import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js'
......
......@@ -126,6 +126,13 @@ export async function refreshPointAlarms() {
publish()
}
/** 写控后立即重算快照并广播(不等下一轮 poll) */
export async function refreshSnapshotNow() {
snapshot = await buildSnapshotFromDevices()
await attachPointAlarms()
publish()
}
async function tick() {
if (tickBusy) return
tickBusy = true
......
......@@ -2,6 +2,7 @@ import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { encodeCsv } from '../devices/csv.js'
import {
applyPointTelemetry,
createDevice,
createPoint,
deleteDevice,
......@@ -229,6 +230,10 @@ devicesRouter.post('/points/:id/write', authRequired, requireRole('admin'), asyn
res.status(502).json({ ok: false, message: result.message })
return
}
// 本地/ mock 立即反映写值,供大屏开关与模拟器联调
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
res.json({ ok: true, message: result.message, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '写入失败' })
......
......@@ -3,7 +3,7 @@ import { authRequired, requireRole } from '../auth/middleware.js'
import { applyPointTelemetry, listPoints } from '../devices/store.js'
import { inferPointRw } from '../devices/pointRw.js'
import { gatewayModbusWrite } from '../gateway/client.js'
import { applyWriteLog } from '../realtime/store.js'
import { applyWriteLog, refreshSnapshotNow } from '../realtime/store.js'
import {
createSensor,
deleteSensor,
......@@ -159,13 +159,30 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re
by: req.user!.username,
gateway: result,
})
if (!result.ok) {
res.status(502).json({ ok: false, message: result.message })
return
}
// 先落本地遥测,保证大屏开关有反馈
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
void refreshSnapshotNow().catch((e) =>
console.warn('[sensors] refresh snapshot after control failed', e),
)
if (!result.ok) {
const msg = result.message || '网关写点失败'
// 网关瞬时不可达:本地已切换,避免前端报超时;业务写失败仍 502
if (/fetch failed|ECONNREFUSED|aborted|timeout|超时/i.test(msg)) {
res.json({
ok: true,
message: `已切换(网关暂不可达:${msg})`,
on,
pointId: point.id,
value,
gatewayDegraded: true,
})
return
}
res.status(502).json({ ok: false, message: msg, on, pointId: point.id, value })
return
}
res.json({ ok: true, message: result.message, on, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '控制失败' })
......
......@@ -86,16 +86,15 @@ function defaultConfig(): VideoCommConfig {
function seedDevices(): VideoDevice[] {
const now = new Date().toISOString()
const rows: Omit<VideoDevice, 'id' | 'createdAt' | 'updatedAt'>[] = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机' },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温' },
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机', scenePoint: null },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温', scenePoint: null },
]
return rows.map((r) => ({
...r,
scenePoint: null as ScenePoint | null,
id: randomUUID(),
createdAt: now,
updatedAt: now,
......
......@@ -10,6 +10,8 @@
全部映射为 **Holding Register(4X)**;`Float`/`Double` 按 IEEE754 大端多寄存器写入。数值按正弦 + 噪声持续变化。
支持主站写:`FC05`(线圈)、`FC06`(单寄存器)、`FC16`(多寄存器)。照明 `KA*` / `开关设定` 写入后会 sticky 保持,不被自动刷新覆盖。
## 安装与运行
```bash
......
......@@ -7,6 +7,7 @@ from __future__ import annotations
import argparse
import math
import re
import socket
import struct
import sys
......@@ -256,12 +257,32 @@ def base_engineering(point: PointDef) -> float:
return 10.0 + (addr % 100) * 0.1
def refresh_values(sims: list[GatewaySim], t0: float) -> None:
def is_sticky_point(point: PointDef) -> bool:
"""照明 KA / 开关设定等:不自动翻转,写后 sticky 保持。"""
n = point.name + point.item_id
if re.search(r"(^|[.\s_])KA\d*($|[.\s_])", n, re.I) or n.strip().upper().startswith("KA"):
return True
if any(k in n for k in ("开关设定", "开关命令", "启停命令")):
return True
return False
def refresh_values(
sims: list[GatewaySim],
t0: float,
sticky: dict[str, set[int]],
) -> None:
t = time.time() - t0
for sim in sims:
if not sim.regs:
continue
held = sticky.setdefault(sim.key, set())
for i, p in enumerate(sim.points):
if p.address in held:
continue
# 照明 KA 保持寄存器当前值(初始 0=关),不自动翻转
if is_sticky_point(p):
continue
base = base_engineering(p)
wave = math.sin(t * 0.15 + i * 0.17 + p.address * 0.01)
if "bool" in p.data_type.lower() or any(
......@@ -273,7 +294,18 @@ def refresh_values(sims: list[GatewaySim], t0: float) -> None:
write_point(sim.regs, p, eng, t)
def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) -> None:
def _exc(tid: int, unit: int, fc: int, code: int = 0x01) -> bytes:
return struct.pack(">HHHBB", tid, 0, 3, unit, (fc & 0xFF) | 0x80) + bytes([code])
def handle_client(
conn: socket.socket,
regs: list[int],
lock: threading.Lock,
sticky: set[int],
label: str,
) -> None:
"""支持 FC03 读、FC05/FC06/FC16 写(照明开关等)。"""
conn.settimeout(5)
try:
while True:
......@@ -293,22 +325,94 @@ def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) ->
if not chunk:
return
body += chunk
if len(body) < 5 or body[0] != 0x03:
# 仅支持 FC03;其它功能码回异常
exc = struct.pack(">HHHBB", tid, 0, 3, unit, (body[0] if body else 0) | 0x80) + b"\x01"
conn.sendall(exc)
if not body:
continue
fc = body[0]
# FC03 Read Holding Registers
if fc == 0x03:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
if count < 1 or count > 125 or addr + count > len(regs):
exc = struct.pack(">HHHBB", tid, 0, 3, unit, 0x83) + b"\x02"
conn.sendall(exc)
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
data = regs[addr : addr + count]
payload = bytes([unit, 0x03, count * 2]) + b"".join(struct.pack(">H", w & 0xFFFF) for w in data)
resp = struct.pack(">HHH", tid, 0, len(payload)) + payload
conn.sendall(resp)
payload = bytes([unit, 0x03, count * 2]) + b"".join(
struct.pack(">H", w & 0xFFFF) for w in data
)
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
# FC05 Write Single Coil → 映射到同址 Holding(0xFF00=ON)
if fc == 0x05:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
raw = struct.unpack(">H", body[3:5])[0]
if addr >= len(regs):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
val = 1 if raw == 0xFF00 else 0
with lock:
regs[addr] = val
sticky.add(addr)
print(f"[write] {label} FC05 addr={addr} → {val}", flush=True)
# 回显请求
payload = bytes([unit, 0x05]) + body[1:5]
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
# FC06 Write Single Register
if fc == 0x06:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
val = struct.unpack(">H", body[3:5])[0]
if addr >= len(regs):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
regs[addr] = val & 0xFFFF
sticky.add(addr)
print(f"[write] {label} FC06 addr={addr} → {val}", flush=True)
payload = bytes([unit, 0x06]) + body[1:5]
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
# FC16 Write Multiple Registers
if fc == 0x10:
if len(body) < 6:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
byte_count = body[5]
if (
count < 1
or count > 123
or byte_count != count * 2
or len(body) < 6 + byte_count
or addr + count > len(regs)
):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
for i in range(count):
w = struct.unpack(">H", body[6 + i * 2 : 8 + i * 2])[0]
regs[addr + i] = w & 0xFFFF
sticky.add(addr + i)
print(f"[write] {label} FC16 addr={addr} count={count}", flush=True)
payload = bytes([unit, 0x10]) + struct.pack(">HH", addr, count)
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
conn.sendall(_exc(tid, unit, fc, 0x01))
except OSError:
return
finally:
......@@ -318,7 +422,12 @@ def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) ->
pass
def serve_gateway(sim: GatewaySim, host: str, lock: threading.Lock) -> None:
def serve_gateway(
sim: GatewaySim,
host: str,
lock: threading.Lock,
sticky: set[int],
) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, sim.port))
......@@ -326,22 +435,32 @@ def serve_gateway(sim: GatewaySim, host: str, lock: threading.Lock) -> None:
print(f"[listen] .{sim.key} {sim.label} → {host}:{sim.port} points={len(sim.points)}", flush=True)
while True:
conn, _ = sock.accept()
threading.Thread(target=handle_client, args=(conn, sim.regs, lock), daemon=True).start()
threading.Thread(
target=handle_client,
args=(conn, sim.regs, lock, sticky, f".{sim.key}"),
daemon=True,
).start()
def run_sync(sims: list[GatewaySim], host: str, interval: float) -> None:
lock = threading.Lock()
sticky: dict[str, set[int]] = {sim.key: set() for sim in sims}
for sim in sims:
sim.regs = [0] * BLOCK_SIZE
refresh_values(sims, time.time())
refresh_values(sims, time.time(), sticky)
for sim in sims:
threading.Thread(target=serve_gateway, args=(sim, host, lock), daemon=True, name=f"mb-{sim.key}").start()
print("[ready] Ctrl+C 退出", flush=True)
threading.Thread(
target=serve_gateway,
args=(sim, host, lock, sticky[sim.key]),
daemon=True,
name=f"mb-{sim.key}",
).start()
print("[ready] 支持 FC03 读 / FC05·FC06·FC16 写(照明 KA 等) Ctrl+C 退出", flush=True)
t0 = time.time()
while True:
try:
with lock:
refresh_values(sims, t0)
refresh_values(sims, t0, sticky)
except Exception as e: # noqa: BLE001
print(f"[update] error: {e}", flush=True)
time.sleep(interval)
......
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY web/package.json ./web/
COPY server/package.json ./server/
RUN npm install --workspace=web --include=dev
COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
VITE_UE_STREAM_URL=$VITE_UE_STREAM_URL
RUN npx vite build
FROM nginx:1.27-alpine
COPY web/nginx.docker.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
# 引擎图标:必须先于业务 /api,iframe 内绝对路径 /api/icons 同域可达
location /api/icons {
proxy_pass http://host.docker.internal:6088/api/icons;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://server:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://server:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# RTEngineHD:同域 /gis → 引擎根(勿转到引擎空目录 /gis/)
# 原生 nginx:127.0.0.1:6088;Docker 容器内需改为 host.docker.internal:6088
location /gis/ {
proxy_pass http://host.docker.internal:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
location = /gis {
return 302 /gis/;
}
# 兼容旧 /rtengine 前缀
location /rtengine/ {
proxy_pass http://host.docker.internal:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
# 引擎 WebSocket 信令(dte.js 使用绝对路径 /peer-stream)
location /peer-stream {
# Docker 时同样改为 host.docker.internal:6088
proxy_pass http://host.docker.internal:6088/peer-stream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# 场景书签 / embed API(gis.js 绝对路径 /designer/...)
location /designer/ {
proxy_pass http://host.docker.internal:6088/designer/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120;
}
location / {
try_files $uri $uri/ /index.html;
}
}
......@@ -7,7 +7,7 @@ export type ScenePropertyField = {
tone?: 'ok' | 'warn' | 'muted'
}
const props = defineProps<{
defineProps<{
open: boolean
title: string
subtitle?: string
......
......@@ -21,6 +21,7 @@ import {
type ScenePoint,
} from '@/composables/useRtengineBridge'
import { useScreenModePois } from '@/composables/useScreenModePois'
import { useLightSceneEffects } from '@/composables/useLightSceneEffects'
import { useAuthStore } from '@/stores/auth'
import type { SensorSnapshot } from '@/types'
import type { VideoDevice } from '@/types/video'
......@@ -355,6 +356,18 @@ async function onLightControl(on: boolean) {
body: JSON.stringify({ on }),
})
nearSwitchOn.value = on
// 乐观更新:开=显示灯 / 关=隐藏灯(watch 会跟 on 状态;此处再强制同步一次)
const sid = nearSensorId.value
const sensors = (realtime.snapshot.sensors || []).map((s) =>
s.id === sid || s.code === sid ? { ...s, on } : s,
)
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
sensors,
})
// false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见
window.setTimeout(() => syncLightEffects(), 0)
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : '控制失败'
} finally {
......@@ -540,12 +553,14 @@ const bridge = useRtengineBridge({
void onEngineReady()
bindIframePointer()
syncModePois()
syncLightEffects()
},
onMouseClick: onMarkerClick,
})
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value)
const { syncModePois } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
function cameraSceneSig(list: VideoDevice[]) {
return list
......@@ -713,18 +728,6 @@ watch(
},
)
function onSelect(id: string) {
// 兼容 marker:id 可能是 areaId 或 roomId
const byArea = areaList.value.find((r) => r.id === id)
if (byArea) {
onSelectArea(byArea.id)
return
}
const byRoom = areaList.value.find((r) => r.roomId === id)
if (byRoom) onSelectArea(byRoom.id)
else realtime.selectRoom(id)
}
onMounted(() => {
void loadCameras()
if (props.layout === 'fill') {
......
import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import type { RtengineLight } from '@/composables/useRtengineBridge'
import type { ScreenMode } from '@/data/screenModePois'
import type { SensorSnapshot } from '@/types'
/** N 电:白光;E 电:淡绿(线性 RGB) */
const COLOR_N: [number, number, number, number] = [1, 1, 1, 1]
const COLOR_E: [number, number, number, number] = [0.45, 1, 0.55, 1]
const SPOT_PREFIX = 'spot:'
/** 灯具挂高:相对设施打点 z(与场景单位一致),略抬高便于朝地板打出光锥 */
const SPOT_HEIGHT = 3.6
/** candela;夜晚压暗后需更高才能在地面形成明显光斑 */
const SPOT_INTENSITY_ON = 90000
/** 衰减半径:覆盖房间尺度 */
const SPOT_RADIUS = 55
/** 内/外锥角(度):收窄后更像聚光而非点光 */
const SPOT_INNER_CONE = 18
const SPOT_OUTER_CONE = 42
/** pitch=-90:光轴垂直朝地板(与 SDK 喷水默认仰角一致) */
const SPOT_PITCH_DOWN = -90
type BridgeLike = {
ready: Ref<boolean>
addLight: (l: RtengineLight) => void
updateLight: (l: Partial<RtengineLight> & { id: string }) => void
removeLight: (id: string) => void
setLightVisible: (id: string, visible: boolean) => void
applyDayNight: (night: boolean) => void
}
function isLightMode(mode: ScreenMode) {
return mode === 'lightN' || mode === 'lightE'
}
function spotId(sensorId: string) {
return `${SPOT_PREFIX}${sensorId}`
}
function lightsOf(snap: { sensors?: SensorSnapshot[] }, mode: ScreenMode): SensorSnapshot[] {
const list = snap.sensors || []
if (mode === 'lightN') return list.filter((s) => s.category === 'lightN')
if (mode === 'lightE') return list.filter((s) => s.category === 'lightE')
return []
}
function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
const isE = s.category === 'lightE'
const z = Number(s.scene?.z) || 51
return {
id: spotId(s.id),
name: s.name,
// 强制聚光灯(非点光),朝地板照射
type: 'spot',
x: Number(s.scene?.x) || 0,
y: Number(s.scene?.y) || 0,
z: z + SPOT_HEIGHT,
yaw: 0,
pitch: SPOT_PITCH_DOWN,
roll: 0,
intensity: on ? SPOT_INTENSITY_ON : 0,
attenuationRadius: SPOT_RADIUS,
innerConeAngle: SPOT_INNER_CONE,
outerConeAngle: SPOT_OUTER_CONE,
color: isE ? COLOR_E : COLOR_N,
useTemperature: false,
castShadows: true,
visible: on,
tag0: 'facility-spot',
tag1: s.category,
tag2: s.id,
}
}
/**
* 照明模式:夜晚压暗 + 每盏设施 spotlight。
* 开 → 显示灯;关 → 隐藏灯(setLightVisible)。
*/
export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) {
const modeStore = useScreenModeStore()
const realtime = useRealtimeStore()
const { mode } = storeToRefs(modeStore)
let lastMode: ScreenMode | '' = ''
/** sensorId → 是否开 */
const knownOn = new Map<string, boolean>()
let activeSpotIds: string[] = []
let nightOn = false
let timer: number | undefined
function clearSpots() {
for (const id of activeSpotIds) {
try {
bridge.setLightVisible(id, false)
bridge.removeLight(id)
} catch {
/* ignore */
}
}
activeSpotIds = []
knownOn.clear()
}
function ensureNight(want: boolean, force = false) {
if (!force && want === nightOn) return
bridge.applyDayNight(want)
nightOn = want
}
/** 开:确保灯存在并显示;关:隐藏 */
function applyOne(s: SensorSnapshot, on: boolean, recreate: boolean) {
const id = spotId(s.id)
const spot = toSpot(s, on)
if (recreate || !activeSpotIds.includes(id)) {
bridge.addLight(spot)
if (!activeSpotIds.includes(id)) activeSpotIds.push(id)
} else {
// 增量更新也带上 type/朝向/锥角,避免引擎回落成点光
bridge.updateLight({
id,
type: 'spot',
intensity: spot.intensity,
color: spot.color,
visible: on,
x: spot.x,
y: spot.y,
z: spot.z,
yaw: spot.yaw,
pitch: spot.pitch,
roll: spot.roll,
attenuationRadius: spot.attenuationRadius,
innerConeAngle: spot.innerConeAngle,
outerConeAngle: spot.outerConeAngle,
})
}
// 用户要求:开=显示 light,关=隐藏 light
bridge.setLightVisible(id, on)
knownOn.set(s.id, on)
}
function sync(force = false) {
if (!enabled.value || !bridge.ready.value) return
const current = mode.value
const lighting = isLightMode(current)
const modeChanged = current !== lastMode
if (!lighting) {
if (nightOn || activeSpotIds.length || modeChanged) {
clearSpots()
ensureNight(false, true)
}
lastMode = current
return
}
ensureNight(true, force || modeChanged)
const sensors = lightsOf(realtime.snapshot, current)
if (force || modeChanged) {
clearSpots()
for (const s of sensors) {
applyOne(s, s.on === true, true)
}
lastMode = current
return
}
// 仅开关变化:显示 / 隐藏,不整表重建
const keep = new Set(sensors.map((s) => s.id))
for (const s of sensors) {
const on = s.on === true
const prev = knownOn.get(s.id)
if (prev === on && activeSpotIds.includes(spotId(s.id))) continue
applyOne(s, on, false)
}
for (const id of [...activeSpotIds]) {
const sid = id.slice(SPOT_PREFIX.length)
if (!keep.has(sid)) {
bridge.setLightVisible(id, false)
bridge.removeLight(id)
activeSpotIds = activeSpotIds.filter((x) => x !== id)
knownOn.delete(sid)
}
}
lastMode = current
}
function schedule() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 80)
}
watch(
[mode, enabled, () => bridge.ready.value],
() => {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(true), 40)
},
{ flush: 'post' },
)
watch(
() => {
const lights = (realtime.snapshot.sensors || []).filter(
(s) => s.category === 'lightN' || s.category === 'lightE',
)
return lights.map((s) => `${s.id}:${s.on === true ? 1 : 0}`).join(';')
},
() => schedule(),
)
return {
/** 默认增量同步(显隐);传 true 时重建全部 spotlight */
syncLightEffects: (force = false) => sync(force),
clearSpots,
}
}
......@@ -27,6 +27,41 @@ export type RtengineMarker = {
leaderColor?: string
}
/** RTEngine 场景灯光(对齐 scene.components light params) */
export type RtengineLight = {
id: string
name?: string
/** spot / point / rect / directional */
type?: 'spot' | 'point' | 'rect' | 'directional'
x: number
y: number
z: number
yaw?: number
pitch?: number
roll?: number
intensity?: number
attenuationRadius?: number
innerConeAngle?: number
outerConeAngle?: number
/** RGBA 0..1 */
color?: [number, number, number, number]
useTemperature?: boolean
temperature?: number
castShadows?: boolean
visible?: boolean
tag0?: string
tag1?: string
tag2?: string
}
/**
* 太阳时:SDK 为 HH.MM(点前小时、点后分钟),如 12.30=12:30。
* 勿传小数小时(12.5);需先换算。
*/
export const DAY_SOLAR_TIME = 12.0
/** 照明模式深夜:0:00,配合阴天/雾压暗 */
export const NIGHT_SOLAR_TIME = 0
/** 默认走同域 /gis 反代,避免 iframe postMessage 跨域 */
export const RTENGINE_SCENE_QUERY = 'scene=mvlpj2yt6379aa29'
export const RTENGINE_PROXY_PATH = '/gis'
......@@ -301,6 +336,29 @@ export function useRtengineBridge(opts: {
;(fn as (a: unknown, b?: unknown) => unknown).call(gis, id, data)
return
}
if (type === 'setLightVisible' && data && typeof data === 'object') {
const o = data as { id?: string; visible?: boolean }
if (o.id != null) {
;(fn as (a: unknown, b?: unknown) => unknown).call(gis, o.id, !!o.visible)
return
}
}
if (type === 'setSolarTime') {
const hours =
typeof data === 'number'
? data
: data && typeof data === 'object'
? Number(
(data as { time?: unknown; solarTime?: unknown; value?: unknown }).time ??
(data as { solarTime?: unknown }).solarTime ??
(data as { value?: unknown }).value,
)
: NaN
if (Number.isFinite(hours)) {
;(fn as (a: unknown) => unknown).call(gis, hours)
return
}
}
;(fn as (a: unknown) => unknown).call(gis, data ?? {})
return
} catch {
......@@ -505,12 +563,244 @@ export function useRtengineBridge(opts: {
callTagFilter('hidePOIs', tag0)
}
/** 更新已有 Marker 文案/坐标(引擎无 update 时降级为 remove + add) */
/** 仅改文案(SDK:updateMarkerText),避免 addMarker 重建造成全屏闪烁 */
function updateMarkerText(id: string, text: string) {
const gis = gisObj()
if (gis && typeof gis.updateMarkerText === 'function') {
try {
gis.updateMarkerText(id, text)
return
} catch {
/* postMessage */
}
}
send('updateMarkerText', { id, text })
// 兼容只认 updateMarker 的宿主
send('updateMarker', { id, text, name: text })
}
/** 更新已有 Marker;禁止再 addMarker(会整点重建 → 大屏闪烁) */
function updateMarker(m: RtengineMarker) {
const payload = markerPayload(m)
const gis = gisObj()
if (gis && typeof gis.updateMarker === 'function') {
try {
gis.updateMarker(payload)
return
} catch {
/* postMessage */
}
}
send('updateMarker', payload)
// 部分版本无 updateMarker:再写一次 add 覆盖
send('addMarker', payload)
}
function lightPayload(l: RtengineLight) {
// 默认聚光灯;pitch=-90 光轴朝下打地板
const type = l.type || 'spot'
const pitch = l.pitch ?? -90
const yaw = l.yaw ?? 0
const roll = l.roll ?? 0
// 与 Marker 同一套场景坐标(厘米/米本地),禁止 isGeo,否则灯会落到错误经纬度看不见
return {
id: l.id,
name: l.name || l.id,
type,
lightType: type,
kind: type,
LightType: type,
isGeo: false,
x: l.x,
y: l.y,
z: l.z,
position: [l.x, l.y, l.z],
yaw,
pitch,
roll,
// 部分宿主认 heading / 俯仰别名
heading: yaw,
rotation: { yaw, pitch, roll },
intensity: l.intensity ?? 8,
attenuationRadius: l.attenuationRadius ?? 10,
innerConeAngle: l.innerConeAngle ?? 18,
outerConeAngle: l.outerConeAngle ?? 42,
// UE SpotLight 常用 Inner/Outer Cone Angle 字段名
innerAngle: l.innerConeAngle ?? 18,
outerAngle: l.outerConeAngle ?? 42,
sourceWidth: 100,
sourceHeight: 100,
useTemperature: l.useTemperature ?? false,
temperature: l.temperature ?? 6500,
castShadows: l.castShadows !== false,
color: l.color || ([1, 1, 1, 1] as [number, number, number, number]),
visible: l.visible !== false,
tag0: l.tag0 || 'facility-light',
tag1: l.tag1 || '',
tag2: l.tag2 || '',
}
}
function addLight(l: RtengineLight) {
send('addLight', lightPayload(l))
}
function updateLight(l: Partial<RtengineLight> & { id: string }) {
const payload: Record<string, unknown> = { id: l.id }
if (l.type) {
payload.type = l.type
payload.lightType = l.type
payload.kind = l.type
}
if (l.intensity != null) payload.intensity = l.intensity
if (l.visible != null) payload.visible = l.visible
if (l.color) payload.color = l.color
if (l.attenuationRadius != null) payload.attenuationRadius = l.attenuationRadius
if (l.innerConeAngle != null) {
payload.innerConeAngle = l.innerConeAngle
payload.innerAngle = l.innerConeAngle
}
if (l.outerConeAngle != null) {
payload.outerConeAngle = l.outerConeAngle
payload.outerAngle = l.outerConeAngle
}
if (l.yaw != null) {
payload.yaw = l.yaw
payload.heading = l.yaw
}
if (l.pitch != null) payload.pitch = l.pitch
if (l.roll != null) payload.roll = l.roll
if (l.x != null) {
payload.x = l.x
payload.lng = l.x
}
if (l.y != null) {
payload.y = l.y
payload.lat = l.y
}
if (l.z != null) {
payload.z = l.z
payload.height = l.z
}
send('updateLight', payload)
}
function removeLight(id: string) {
send('removeLight', { id })
}
function clearLights() {
send('clearLights', {})
}
function setLightVisible(id: string, visible: boolean) {
const on = !!visible
// SDK:gis.setLightVisible(id, false) 或 { tag0 }
send('setLightVisible', { id, visible: on })
const gis = gisObj()
if (gis && typeof gis.setLightVisible === 'function') {
try {
gis.setLightVisible(id, on)
} catch {
/* send 已兜底 */
}
}
}
/** 太阳时 HH.MM;也可直接传数字 */
function setSolarTime(tm: number) {
const t = Number(tm)
if (!Number.isFinite(t)) return
// 直调与 postMessage 两种形态都发,兼容不同宿主
send('setSolarTime', t)
send('setSolarTime', { time: t, solarTime: t, value: t, data: t })
}
function setWeather(
weatherId: number | Record<string, unknown>,
extras?: Record<string, unknown>,
) {
if (typeof weatherId === 'number') {
send('setWeather', extras ? { weatherId, weather: String(weatherId), ...extras } : { weatherId, weather: String(weatherId) })
} else {
send('setWeather', weatherId)
}
}
/**
* SDK:setSunParam(intensity, fogDensity, h, s, v, a)
* intensity 近 0 + 高 fogDensity 可显著压暗;v 近 0 进一步压环境明度
*/
function setSunParam(
intensity: number,
fogDensity = 0,
h = 0,
s = 0,
v = intensity,
a = 1,
) {
const gis = gisObj()
if (gis && typeof gis.setSunParam === 'function') {
try {
gis.setSunParam(intensity, fogDensity, h, s, v, a)
return
} catch {
/* postMessage */
}
}
send('setSunParam', { intensity, fogDensity, h, s, v, a, timeOfDay: intensity })
postToFrame(win(), opts.cloudUrl, {
type: 'setSunParam',
data: [intensity, fogDensity, h, s, v, a],
messageId: `web-sun-${Date.now()}`,
})
}
function setFog(fog: number) {
const gis = gisObj()
if (gis && typeof gis.setFog === 'function') {
try {
gis.setFog(fog)
return
} catch {
/* fallthrough */
}
}
send('setFog', { fog })
}
function setLighting(opts: {
sunBrightness?: number
ambientFill?: number
shadowSoftness?: number
intensity?: number
}) {
const intensity = opts.intensity ?? opts.sunBrightness ?? 1
setSunParam(intensity, intensity < 0.2 ? 0.7 : 0)
}
function applyDayNight(night: boolean) {
if (night) {
// 尽量全黑:午夜 + 浓雾阴天 + 太阳强度归零,只留 spotlight
setSolarTime(NIGHT_SOLAR_TIME)
setWeather(5, {
// 5=雾;比阴天更能压掉环境光
fog: 1,
fogDensity: 1,
fogIntensity: 1,
cloudCoverage: 1,
wind: 0,
transition: 0.08,
})
// intensity=0, fog=1, HSV.v=0 → 尽量全黑
setSunParam(0, 1, 0, 0, 0, 1)
setFog(1)
// 部分版本 intensity 映射云量;再压一轮太阳时确保仍是深夜
setSolarTime(NIGHT_SOLAR_TIME)
} else {
setSolarTime(DAY_SOLAR_TIME)
setWeather(1, { fog: 0, fogDensity: 0, cloudCoverage: 0.15, transition: 0.2 })
setSunParam(1, 0)
setFog(0)
}
}
let presetsShown = false
......@@ -659,6 +949,16 @@ export function useRtengineBridge(opts: {
showPois,
hidePois,
updateMarker,
updateMarkerText,
addLight,
updateLight,
removeLight,
clearLights,
setLightVisible,
setSolarTime,
setWeather,
setLighting,
applyDayNight,
showScenePresets,
markReady,
flushPendingFlies,
......
......@@ -32,6 +32,7 @@ type BridgeLike = {
addMarkers: (list: RtengineMarker[]) => void
removeMarker: (id: string) => void
updateMarker: (m: RtengineMarker) => void
updateMarkerText?: (id: string, text: string) => void
setMarkerVisible: (id: string, visible: boolean) => void
showPois: (tag0?: string) => void
hidePois: (tag0?: string) => void
......@@ -150,16 +151,6 @@ function sensorsOf(
return (snap.sensors || []).filter((s) => cats.includes(s.category))
}
const SENSOR_MODES = new Set([
'pressure',
'lightN',
'lightE',
'fridge',
'vav',
'camera',
'access',
])
/** 主要分类走设施快照;监控仍可由 SceneViewport 叠加视频台账点 */
function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarker[] {
if (mode === 'pressure') {
......@@ -202,26 +193,31 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
const { mode } = storeToRefs(modeStore)
let activeIds: string[] = []
let lastSig = ''
/** 结构签名:id/图标/坐标;不含实时数值,避免每轮采集触发整表闪烁 */
let lastStructSig = ''
let lastTextSig = ''
let lastMode: ScreenMode | '' = ''
let lastCadMode: ScreenMode | '' = ''
/** 已对引擎下发的 show/hide tag 集合签名,避免重复 showPOIs 打闪 */
let lastTagVisSig = ''
let timer: number | undefined
let poll: number | undefined
let pollsLeft = 0
function armPoll() {
pollsLeft = 24
// 仅在切换分类后短暂补一次「单点可见」,绝不反复 showPOIs/hidePOIs
pollsLeft = 2
if (poll) return
poll = window.setInterval(() => {
pollsLeft -= 1
if (enabled.value && bridge.ready.value) {
applyCadVisibility(mode.value)
revealActiveMarkers()
for (const id of activeIds) bridge.setMarkerVisible(id, true)
}
if (pollsLeft <= 0 && poll) {
window.clearInterval(poll)
poll = undefined
}
}, 500)
}, 800)
}
/** 当前分类应显示的 tag0 集合 */
......@@ -236,14 +232,28 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
return new Set()
}
function tagVisSig(showTags: Set<string>) {
return [...showTags].sort().join(',')
}
/** 引擎预设 / CAD 点:按分类显隐(摄像头留给 SceneViewport) */
function applyCadVisibility(current: ScreenMode) {
function applyCadVisibility(current: ScreenMode, force = false) {
const showTags = tagsForMode(current)
const sig = tagVisSig(showTags)
const modeSame = current === lastCadMode
const tagsSame = sig === lastTagVisSig
if (!force && modeSame && tagsSame) return
lastCadMode = current
lastTagVisSig = sig
// 仅分类切换时才对引擎做 tag 级 show/hide(整屏刷新很重)
if (force || !modeSame || !tagsSame) {
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
if (showTags.has(tag)) bridge.showPois(tag)
else bridge.hidePois(tag)
}
}
const live = bridge.listMarkers?.() || []
for (const marker of live) {
......@@ -258,11 +268,6 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
function revealActiveMarkers() {
for (const id of activeIds) bridge.setMarkerVisible(id, true)
const showTags = tagsForMode(mode.value)
for (const tag of showTags) {
if (tag === 'camera') continue
bridge.showPois(tag)
}
}
function clearModeMarkers() {
......@@ -282,7 +287,40 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
}
}
activeIds = []
lastSig = ''
lastStructSig = ''
lastTextSig = ''
lastCadMode = ''
lastTagVisSig = ''
}
function structSigOf(markers: RtengineMarker[]) {
// 按 id 排序,避免传感器数组顺序抖动触发整表重建
return [...markers]
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => {
const x = Number(m.x).toFixed(5)
const y = Number(m.y).toFixed(5)
const z = Number(m.z).toFixed(2)
return `${m.id}|${m.icon || ''}|${m.type}|${x},${y},${z}`
})
.join(';')
}
function textSigOf(markers: RtengineMarker[]) {
return [...markers]
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => `${m.id}|${m.text || ''}`)
.join(';')
}
function refreshTexts(markers: RtengineMarker[]) {
for (const m of markers) {
if (bridge.updateMarkerText && m.text != null) {
bridge.updateMarkerText(m.id, String(m.text))
} else {
bridge.updateMarker(m)
}
}
}
function sync(force = false) {
......@@ -292,19 +330,24 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
const modeChanged = current !== lastMode
const snap = realtime.snapshot
const markers = markersForMode(current, snap)
const sig = markers
.map((m) => `${m.id}|${m.text}|${m.icon || ''}|${m.x},${m.y},${m.z}`)
.join(';')
const structSig = structSigOf(markers)
const textSig = textSigOf(markers)
if (!force && !modeChanged && sig === lastSig) {
applyCadVisibility(current)
revealActiveMarkers()
// 结构未变:只改文案,绝不 show/hide / addMarker
if (!force && !modeChanged && structSig === lastStructSig) {
if (textSig !== lastTextSig) {
refreshTexts(markers)
lastTextSig = textSig
}
lastMode = current
return
}
// 分类切换:先卸旧点、滤掉其它 tag,再写入当前类
if (force || modeChanged) {
clearModeMarkers()
lastCadMode = ''
lastTagVisSig = ''
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
bridge.hidePois(tag)
......@@ -323,24 +366,26 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
if (force || modeChanged || !activeIds.length || markers.length !== activeIds.length) {
bridge.addMarkers(markers)
} else {
for (const m of markers) bridge.updateMarker(m)
refreshTexts(markers)
}
}
activeIds = markers.map((m) => m.id)
lastSig = sig
lastStructSig = structSig
lastTextSig = textSig
lastMode = current
applyCadVisibility(current)
// 引擎异步过滤:稍后强制再亮一次当前运行时点
applyCadVisibility(current, force || modeChanged)
revealActiveMarkers()
window.setTimeout(() => revealActiveMarkers(), 200)
window.setTimeout(() => revealActiveMarkers(), 800)
if (force || modeChanged) {
window.setTimeout(() => revealActiveMarkers(), 400)
}
}
function scheduleSync() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 300)
// 实时数值合并;文案用 updateMarkerText,不再重建 Marker
timer = window.setTimeout(() => sync(false), 1200)
}
async function syncModePois() {
......
......@@ -56,12 +56,6 @@ const typeLabel: Record<DeviceType, string> = {
'nvr-ch': '海康 NVR 通道',
}
const protocolLabel: Record<VideoProtocol, string> = {
sdk: 'HCNetSDK',
rtsp: 'RTSP',
isapi: 'ISAPI',
}
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
const previewList = computed(() => devices.value.filter((d) => d.enabled))
const selected = computed(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment