Compare commits
6 Commits
c8ca009d13
...
950cd1c4cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 950cd1c4cd | |||
| 2afab19463 | |||
| c930dff6c4 | |||
| 043766b84e | |||
| 9fbc218e10 | |||
| 8b3a1d79d9 |
@@ -224,6 +224,29 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
||||
`EXPORT_BASE_COLOR_OVERRIDES`、`EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表,
|
||||
但它们只是旧 `.blend` 兼容回退。新材质不要只写旧表。
|
||||
|
||||
### 交通信号倒计时字体
|
||||
|
||||
`assets/fonts/7LED-1.ttf` 是项目纳入版本管理的倒计时字体。它的字形是反向轮廓:可见
|
||||
的 LED 段是字体轮廓里的孔,而不是普通实心文字。因此 Blender 侧不能直接把文字曲线
|
||||
转成普通填充面(会得到“发光背景+黑色数字”),也不能依赖曲线描边。正确做法是在
|
||||
`blender/osmassets/traffic_signals.py` 中采样负 Bezier 轮廓,构造带前后盖面的挤出棱柱,
|
||||
使 LED 段成为实心发光几何。数字 mesh 必须先在 Blender 中单独渲染确认,再进入 Cesium
|
||||
导出;导出器出现“Could not calculate tangents”只表示这些无 UV 的纯色网格没有切线,
|
||||
不等同于倒计时集合为空或几何失败。
|
||||
|
||||
### 共享与拆分动态资产
|
||||
|
||||
倒计时数字按 phase group 共享 20 个数字 mesh(0-19),不要按信号灯复制网格。Cesium
|
||||
阶段必须生成三个动态 GLB:`traffic-signals-dynamic.glb` 只含灯珠,
|
||||
`traffic-signals-countdown-0.glb` 和 `traffic-signals-countdown-1.glb` 分别含两个相位组的
|
||||
倒计时节点。两个倒计时模型与灯珠模型使用同一个 `modelMatrix`,浏览器只切换当前数字
|
||||
节点,并给整个倒计时模型设置 `color` + `ColorBlendMode.REPLACE`,从而让字色跟随当前
|
||||
红/黄/绿相位且不增加每个灯的材质/几何副本。
|
||||
|
||||
导出器按完整材质名包含 `Countdown Group 0` / `Countdown Group 1` 判断分组;不能用
|
||||
集合名的精确相等比较,否则实际材质名 `Traffic Signal Countdown Group 0` 会被误判为
|
||||
空集合。
|
||||
|
||||
### 为什么新资产总是"发黑"
|
||||
|
||||
`export_cesium.py:38-54` 记录了这个反复出现的问题:
|
||||
@@ -258,6 +281,8 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
||||
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
|
||||
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
|
||||
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |
|
||||
| 直接用 Cesium `Model.getMaterial().setValue()` 改普通 glTF PBR 材质 | 运行时数字仍保持原色,不能实现相位字色 |
|
||||
| 每个信号灯各自生成 0-19 全套倒计时 mesh | 节点和几何按信号数量线性膨胀;应按两个 phase group 共享 |
|
||||
|
||||
## 第三方资产导入的源文件边界
|
||||
|
||||
|
||||
@@ -165,6 +165,16 @@ def test_spacing_carries_across_segment_joins(self):
|
||||
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`,
|
||||
放进 `geom.py` 就立刻获得测试覆盖的资格。
|
||||
|
||||
### 以 MeshBatch 为边界的静态设施测试
|
||||
|
||||
少数 bpy 要素模块的价值在于确定性地向 `MeshBatch` 追加顶点与面,而不是调用 bpy API
|
||||
本身。对这类模块(例如 `osmassets/traffic_signals.py`),应在 `blender/tests/` 用假的
|
||||
`osmassets.mesh.MeshBatch` 导入模块,断言有效输入的装配数量和关键几何方向。这样可覆盖
|
||||
“校验函数意外返回空、所有要素被静默跳过”这一类错误,不必依赖可用的 Blender 进程。
|
||||
|
||||
测试必须在本文件列出的 `python3 -m unittest discover blender/tests` 命令下独立运行;测试
|
||||
文件自己添加 `blender/` 到 `sys.path`,不能依赖其他测试的导入顺序。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
@@ -343,6 +343,84 @@ out.vehicleStopLines = crosswalkData.stopLines;
|
||||
// 原生 lane_markings 停止线不得复制到输出。
|
||||
```
|
||||
|
||||
## 信号锚点的跨阶段消费
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用
|
||||
`<geojsonDir>/traffic_signals.json`。它是附属 intermediates 产物,而不是第十个
|
||||
osm2streets/QGIS 图层。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/<area>.json --stages intermediates,blender,cesium,preview
|
||||
```
|
||||
|
||||
`normalizeAreaConfig()` 将默认路径归一化为:
|
||||
|
||||
```js
|
||||
area.outputs.trafficSignals
|
||||
// <areaDir>/osm2streets_web_out/traffic_signals.json
|
||||
```
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- `build-area.js:writeTrafficSignals()` 是锚点 JSON 的生产者,调用
|
||||
`traffic-signals.js:readTrafficSignals()`,输入为 `vehicle_stop_lines.geojson` 和
|
||||
`intersection_surface.geojson`。
|
||||
- `intermediates` 与 `reimport` 都必须在其 GeoJSON 产物稳定后重写锚点,确保 QGIS
|
||||
人工修补反导入后,Blender 和 preview 仍使用同一事实。
|
||||
- `blender` 和 `preview` 在启动前必须检查该文件存在;前者把静态设施写进 `05_Props`,
|
||||
后者只叠加动态灯珠、倒计时和车辆相位。
|
||||
- `traffic_signals.json` 不得加入 `SCENE_LAYERS`、GeoPackage 或 QGIS 工程;这些层只能
|
||||
继续包含九个道路场景图层。
|
||||
- `layout.countdownLateralMeters` 等几何字段是 Blender/preview 的共同事实源;横向正值统一
|
||||
表示相对来车方向的右侧。不得在任一消费方用独立的负号约定替代它。
|
||||
- `layout.mastHeightMeters` 与 `layout.headCenterHeightMeters` 必须相等,表示横杆与灯壳的
|
||||
中心对齐;`lensVerticalOffsetsMeters` 以灯壳中心为基准,正值向上、负值向下。当前倒计时牌
|
||||
垂直偏移为 `0`,必须贴在横杆上而非悬挂。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| `intermediates` 或 `reimport` 有合法停止线和路口面 | 写出 `version` 与 `signals` 数组,即使数组为空 |
|
||||
| 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` |
|
||||
| 单个停止线无法可靠关联路口 | 锚点生成器跳过该项,其他进口照常输出 |
|
||||
| 用户仅修改 QGIS 后运行 `reimport` | 重新生成锚点,不沿用旧坐标 |
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good:完整构建后,GLB 的静态灯杆/灯壳和 Cesium 动态灯珠使用同一份 anchor。
|
||||
- Base:没有可用进口时写出空 `signals`,Blender 继续生成其余场景。
|
||||
- Bad:在 Cesium 中再次推导灯杆位置,或把 anchors 导入 GeoPackage;两者都会产生位置
|
||||
漂移或污染人工 QGIS 工作流。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `npm run test:budgets`:断言默认锚点路径位于 `osm2streets_web_out/`。
|
||||
- `npm run test:preview-assets`:断言预览配置仍传递相对锚点 URL。
|
||||
- 目标区域完整构建:确认 `traffic_signals.json` 与 Blender/preview stage manifest 均存在。
|
||||
- Blender 可运行环境:检查 `SCENE_DONE.traffic_signals`、主 GLB 的 `05_Props` 设施,
|
||||
以及 Cesium 动态叠层与静态灯壳对齐。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
错误:
|
||||
|
||||
```js
|
||||
// preview 运行时再次从两份 GeoJSON 推导另一组锚点。
|
||||
const signals = buildTrafficSignals(stopLines, intersections);
|
||||
```
|
||||
|
||||
正确:
|
||||
|
||||
```js
|
||||
// Blender 与 preview 都消费 intermediates 写出的同一份文件。
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
```
|
||||
|
||||
## 区域诊断命令
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -201,6 +201,20 @@ GLB 停留在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSO
|
||||
|
||||
`scenePlacement(metadata)`(`:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。**
|
||||
|
||||
## 交通信号动态覆盖层
|
||||
|
||||
metadata 的动态资产契约如下:
|
||||
|
||||
- `category="dynamic"`:灯珠节点,继续按相位切换红/黄/绿 lens 的 `show`。
|
||||
- `category="countdown"` 且 `phaseGroup` 为 `0` 或 `1`:对应相位组的倒计时模型;模型内
|
||||
共享 20 个数字节点,不按每个信号复制数字。
|
||||
|
||||
三个模型必须使用完全相同的 `placement.modelMatrix`。倒计时颜色只能通过模型级
|
||||
`model.color` 配合 `Cesium.ColorBlendMode.REPLACE` 设置;普通 glTF PBR 材质的
|
||||
`getMaterial().setValue()` 在本项目验证中不能可靠修改运行时字色,禁止作为实现路径。
|
||||
倒计时数字的显示逻辑只改变当前数字节点的 `show`,颜色由该 phase group 的当前灯色
|
||||
统一设置。加载失败属于部分资产失败:应进入诊断而不清空主场景。
|
||||
|
||||
## 语义检查资产
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
@@ -89,3 +89,22 @@ edges.push(makeEdge(way, refs.reverse(), coords.reverse(), "backward"));
|
||||
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
|
||||
if (!isOneWay(oneway)) edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
|
||||
```
|
||||
|
||||
## 信号动态 GLB 契约
|
||||
|
||||
`traffic_signals.json` 的 `pose.*` 是 Blender 静态设施、动态灯珠和倒计时共享的锚点。Blender
|
||||
把发光灯珠导出为独立的 `*-traffic-signals-dynamic.glb`,preview 必须使用与主 GLB 相同的
|
||||
`scenePlacement(metadata).modelMatrix` 加载它;Cesium 仅按命名灯珠节点切换 `show`。倒计时
|
||||
例外:它由 Cesium Entity 从 `pose.countdown` 的 ENU 坐标与面向直接绘制,避免 glTF 轴变换
|
||||
反转七段字形。
|
||||
|
||||
动态表面不能与静态镜片或倒计时外壳共面:镜片和数码管必须沿本地 `face` 轴前移
|
||||
`(static_depth + dynamic_depth) / 2 + epsilon`。这是模型局部几何关系,不是经纬度修正;
|
||||
否则静态网格会通过深度测试遮住发光状态,表现为灯不切换或数字不可见。
|
||||
|
||||
错误:在 Cesium 用 `fromDegrees`/Entity 重新计算动态设施,或将动态网格中心与静态表面中心
|
||||
重合。
|
||||
|
||||
正确:Blender 生成命名节点 `TrafficSignalDynamic_<signal-id>_<state>`;浏览器在同一 model
|
||||
matrix 下加载该 GLB,并只切换这些灯珠节点。倒计时 Entity 使用 `pose.countdown` 的经纬度、
|
||||
高度、`faceHeadingDegrees` 生成与牌面相同的 ENU 坐标轴。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,44 @@
|
||||
# 设计:Cesium 路口信号灯可视化
|
||||
|
||||
## 分层边界
|
||||
|
||||
信号锚点不是 QGIS 业务图层:它不进入 GeoPackage、场景九图层或 QGIS 工程。`intermediates`
|
||||
在 `osm2streets_web_out/traffic_signals.json` 写出它;Blender 和 preview 都消费同一份文件。
|
||||
|
||||
Blender 的 `05_Props` 负责所有静态设施:灯杆、横杆、灯头、熄灭灯珠和倒计时牌外壳。它们
|
||||
随 `.blend` 和主 GLB 导出,成为正式场景的一部分。Cesium 预览只负责动态覆盖层:当前相位的
|
||||
发光灯珠、七段倒计时数字、Signals 显示开关,以及车辆在红黄灯前的等待。这样静态造型只有
|
||||
一份,浏览器不再用临时 Entity 重复搭建设施。
|
||||
|
||||
## 锚点与几何
|
||||
|
||||
中间阶段已有 `vehicle_stop_lines` 和 `intersection_surface`。信号锚点生成器将停止线的
|
||||
中点作为进口横向基准,依据相交路口面和停止线两端的方向判定来车朝向;灯杆置于停止线的
|
||||
侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时,
|
||||
不输出锚点。
|
||||
|
||||
输出保存灯杆坐标、对应停止线坐标、朝向、稳定 ID 和 `layout` 几何契约,避免 Blender 或
|
||||
浏览器重新解析 GeoJSON 或 OSM。`layout` 包含灯头、灯珠、横杆和倒计时牌的尺寸与偏移;
|
||||
其中横向偏移以车辆行驶方向为基准,正值表示驾驶员右侧;`mastHeightMeters` 与
|
||||
`headCenterHeightMeters` 是横杆和灯壳的共同中心高度。三颗灯珠相对灯壳中心排列,而倒计时牌
|
||||
的垂直偏移为零、固定在横杆上。
|
||||
Blender 使用
|
||||
`Projector.xy((longitude, latitude))` 转成本地米制坐标,并以 `headingDegrees` 旋转;Cesium
|
||||
以同一字段派生地理位置与灯面朝向。
|
||||
每个路口按相对进口方向分为两组对向相位;统一循环绿、黄、全红切换。
|
||||
|
||||
预览将每条路线按累计米数投影到信号停止线。只有距离阈值内且行驶方向与信号进口一致的
|
||||
匹配才形成停车点。车辆的累计里程由 `clock.onTick` 推进;下一停车点为红或黄时将里程夹在
|
||||
停止线前,绿灯后从同一位置继续。没有停车点的路线保留原速度循环。
|
||||
|
||||
## 预览交互
|
||||
|
||||
预览加载锚点 JSON 失败时记录 warning,场景、路线与车辆仍可用。加载成功时信号灯默认显示,
|
||||
并在现有 View 控件中提供独立 Signals 复选框。动态灯珠和数字以 emissive 材质区分点亮和
|
||||
熄灭状态,不依赖环境光;静态部分由 GLB 的低多边形 MeshBatch 几何承载。
|
||||
|
||||
## 风险与回退
|
||||
|
||||
信号灯是示意设施,不能视为 OSM 语义。复杂交叉口或人工修补后的不完整标线宁可跳过,也不
|
||||
摆放到行车道中央。回退时删除附属锚点输出、`05_Props` 信号构件和 Cesium 动态覆盖层即可;
|
||||
QGIS、道路/标线和既有路线 JSON 不受影响。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 实施计划:Cesium 路口信号灯可视化
|
||||
|
||||
1. 复用 `traffic-signals.js` 的锚点推导,在 `intermediates` 阶段把附属
|
||||
`traffic_signals.json` 写入 `geojsonDir`;不修改 `SCENE_LAYERS`、GeoPackage 或 QGIS。
|
||||
2. 在 `blender/osmassets/traffic_signals.py` 用共享低多边形 MeshBatch 几何装配静态信号设施,
|
||||
并在 `generate_scene.py` 读取锚点、投影坐标、置入 `05_Props` 和写入计数。
|
||||
3. 在 `catalog.MATERIALS` 末尾追加信号设施材质和 Cesium 导出补偿,保证新 GLB 在 Cesium
|
||||
中不会发黑或材质索引漂移。
|
||||
4. 让 preview 直接读取 intermediates 的锚点文件;删除 Cesium 对立杆、横杆、灯头、熄灭灯珠
|
||||
和外壳的构造,只保留精确对齐的动态灯珠、数字与既有相位/车辆等待。
|
||||
5. 更新 Node 与纯 Python 测试,构建目标区域并用 parity 检查 GLB 差异只包含预期新增设施;
|
||||
人工核对主 GLB 静态构件和 preview 动态覆盖层。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
npm run test:preview-assets
|
||||
node --check scripts/lib/cesium-preview.js
|
||||
python3 -m unittest discover blender/tests
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates,blender,cesium,preview
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 已验证决策与故障记录
|
||||
|
||||
- `assets/fonts/7LED-1.ttf` 已纳入版本管理;该字体是反向 LED 轮廓。直接填充文字会得到
|
||||
绿色背景/黑色字,曲线描边会得到空心描边字;当前实现改为采样负轮廓并构造实心挤出
|
||||
棱柱,独立 Blender 渲染已确认数字形状正确。
|
||||
- 为支持相位字色且避免复制几何,动态导出拆为灯珠 GLB + countdown group 0/1 两个 GLB。
|
||||
预览三者共用同一 placement,倒计时模型使用 `colorBlendMode=REPLACE` 做模型级换色。
|
||||
- 失败方案:Cesium `Model.getMaterial().setValue()` 修改普通 glTF PBR uniforms,用户实测
|
||||
数字仍为绿色,不能恢复使用。
|
||||
- 失败陷阱:导出器的 `groups` 是完整材质名集合,不能检查精确字符串 `"Countdown Group 0"`;
|
||||
必须用 `"Countdown Group 0" in name` 的包含判断,否则导出阶段报
|
||||
`Traffic countdown collection 0 is empty`。
|
||||
- glTF 的 `Could not calculate tangents` 警告来自无 UV 的纯色倒计时 mesh;只要三个 GLB
|
||||
均成功生成,它不是阻断错误。
|
||||
|
||||
## 回退
|
||||
|
||||
删除静态信号 Blender 模块、附属锚点输出和浏览器动态覆盖层;QGIS、道路/标线和既有路线
|
||||
JSON 不受影响。
|
||||
@@ -0,0 +1,55 @@
|
||||
# Cesium 路口信号灯可视化
|
||||
|
||||
## Goal
|
||||
|
||||
在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、
|
||||
转向箭头和车辆巡航有清晰的交通控制参照。车辆应在对应停止线前遵守同一套相位,并在
|
||||
绿灯放行后继续巡航。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- `scripts/lib/cesium-preview.js` 目前只加载场景资产、巡航路线与车辆;没有信号灯图层、
|
||||
模型或相位状态。
|
||||
- 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。
|
||||
- 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补
|
||||
边界、道路生成与既有连续路线逻辑。
|
||||
- Cesium 当前同时绘制灯杆、横杆、灯头、熄灭灯珠、倒计时外壳,以及随相位变化的灯珠和
|
||||
七段数字。这使静态设施只存在于验证层,难以随主场景维护。
|
||||
- `05_Props` 集合已经进入主 `.blend` 和 Cesium GLB;`traffic-signals.js` 已是停止线和
|
||||
路口面推导信号锚点的唯一事实源。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 在道路进口侧、停止线附近呈现简洁而可辨识的交通信号灯,并在不遮挡车道标线的前提下
|
||||
面向来车方向。
|
||||
- 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。
|
||||
- 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。
|
||||
- 车辆接近已匹配的停止线时,红灯和黄灯必须停车,绿灯继续通行;相位切换后应自然恢复
|
||||
移动,不得瞬移到路口另一侧。
|
||||
- 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。
|
||||
- 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠
|
||||
几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。
|
||||
- 灯杆、横杆、灯壳、熄灭灯珠和倒计时牌外壳必须成为 `05_Props` 中的静态场景几何,随
|
||||
主 GLB 导出;Cesium 只保留与这些几何严格对齐的发光灯珠、七段倒计时数字、显示开关和
|
||||
车辆相位等待。
|
||||
- 信号锚点必须在 `intermediates` 阶段写入 `osm2streets_web_out/traffic_signals.json`,由
|
||||
Blender 与 preview 共用;不得纳入 `SCENE_LAYERS`、GeoPackage 或 QGIS 工程。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 仅运行 `intermediates,blender,cesium` 后,主 GLB 已包含位于路口进口侧的灯杆、横杆、
|
||||
灯头、熄灭灯珠和倒计时牌外壳;静态几何位置与停止线/
|
||||
斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。
|
||||
- [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。
|
||||
- [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。
|
||||
- [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的
|
||||
路线保持原有循环巡航,不因信号锚点缺失而卡住。
|
||||
- [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。
|
||||
- [ ] preview 重建后,动态灯珠和数字与 GLB 中相应灯头、倒计时外壳对齐,且无 Cesium
|
||||
重复的杆、横杆、灯壳或外壳实体。
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||
- Lightweight tasks can remain PRD-only.
|
||||
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "cesium-traffic-signals",
|
||||
"name": "cesium-traffic-signals",
|
||||
"title": "Cesium 路口信号灯可视化",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-05",
|
||||
"completedAt": "2026-08-06",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 24
|
||||
- **Last Active**: 2026-08-05
|
||||
- **Total Sessions**: 25
|
||||
- **Last Active**: 2026-08-06
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~512 | Active |
|
||||
| `journal-1.md` | ~533 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 25 | 2026-08-06 | Cesium traffic signal countdowns | `0e1574f` | `main` |
|
||||
| 24 | 2026-08-05 | 拆分 LowPoly Cars 车辆资产 | `3108336`, `2489b8a` | `main` |
|
||||
| 23 | 2026-08-05 | 车辆连续巡航与转弯 | `30846b6` | `main` |
|
||||
| 22 | 2026-08-05 | Cesium semantic inspection preview | `607d8fc` | `main` |
|
||||
|
||||
@@ -510,3 +510,24 @@ Added semantic Cesium inspection assets and controls; fixed compressed metadata
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 25: Cesium traffic signal countdowns
|
||||
|
||||
**Date**: 2026-08-06
|
||||
**Task**: Cesium traffic signal countdowns
|
||||
**Branch**: `main`
|
||||
|
||||
### Summary
|
||||
|
||||
Added shared 7LED countdown geometry, split dynamic Cesium assets by phase group for reliable lamp-matched colors, documented the inverse-font and exporter grouping contracts, and verified the preview asset and Blender Python test suites.
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `0e1574f` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
BIN
assets/fonts/7LED-1.ttf
Normal file
BIN
assets/fonts/7LED-1.ttf
Normal file
Binary file not shown.
8
assets/fonts/SOURCES.md
Normal file
8
assets/fonts/SOURCES.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 7-LED Font
|
||||
|
||||
`7LED-1.ttf` is the countdown-display font used by Blender when generating
|
||||
traffic-signal dynamic meshes. Source file supplied locally by the project
|
||||
owner from `Downloads/7-LED/7LED-1.ttf`.
|
||||
|
||||
Copyright information embedded in the font: Philippe Blondel, 2010,
|
||||
www.philing.net.
|
||||
@@ -127,12 +127,12 @@ EXPORT_EMISSION_OVERRIDES = {
|
||||
|
||||
|
||||
def cli_args():
|
||||
values = {"blend": None, "glb": None, "metadata": None}
|
||||
values = {"blend": None, "glb": None, "metadata": None, "dynamic_glb": None, "countdown_0_glb": None, "countdown_1_glb": None}
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i].startswith("--") and i + 1 < len(argv):
|
||||
values[argv[i][2:]] = argv[i + 1]
|
||||
values[argv[i][2:].replace("-", "_")] = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
@@ -609,6 +609,8 @@ def export(args):
|
||||
|
||||
material_map = {}
|
||||
meshes = []
|
||||
dynamic_meshes = []
|
||||
countdown_meshes = {0: [], 1: []}
|
||||
unwrapped = set()
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type != "MESH":
|
||||
@@ -617,7 +619,14 @@ def export(args):
|
||||
continue
|
||||
if obj.hide_viewport or obj.hide_render:
|
||||
continue
|
||||
meshes.append(obj)
|
||||
if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection):
|
||||
groups = {slot.material.name for slot in obj.material_slots if slot.material}
|
||||
group = (0 if any("Countdown Group 0" in name for name in groups)
|
||||
else 1 if any("Countdown Group 1" in name for name in groups)
|
||||
else None)
|
||||
(countdown_meshes[group] if group is not None else dynamic_meshes).append(obj)
|
||||
else:
|
||||
meshes.append(obj)
|
||||
apply_mesh_modifiers(obj)
|
||||
# Hundreds of grass tufts share four mesh datablocks; unwrapping and
|
||||
# triangulating are properties of the mesh, so once per datablock.
|
||||
@@ -642,6 +651,14 @@ def export(args):
|
||||
slot.material = material_map[source.name]
|
||||
|
||||
export_glb(args["glb"], meshes)
|
||||
if args.get("dynamic_glb"):
|
||||
if not dynamic_meshes:
|
||||
raise RuntimeError("Dynamic traffic signal collection is empty")
|
||||
export_glb(args["dynamic_glb"], dynamic_meshes)
|
||||
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
|
||||
if not countdown_meshes[group]:
|
||||
raise RuntimeError("Traffic countdown collection %d is empty" % group)
|
||||
export_glb(args[key], countdown_meshes[group])
|
||||
semantic_assets = semantic_asset_specs(args["glb"], meshes)
|
||||
for asset in semantic_assets:
|
||||
export_glb(asset["path"], asset["meshes"])
|
||||
@@ -664,6 +681,19 @@ def export(args):
|
||||
"type": "model",
|
||||
"url": os.path.basename(args["glb"]),
|
||||
"enabled": True,
|
||||
}, {
|
||||
"id": "traffic-dynamic",
|
||||
"label": "Traffic signals dynamic",
|
||||
"type": "model",
|
||||
"url": os.path.basename(args["dynamic_glb"]) if args.get("dynamic_glb") and dynamic_meshes else "",
|
||||
"enabled": True,
|
||||
"category": "dynamic",
|
||||
}, {
|
||||
"id": "traffic-countdown-0", "label": "Traffic countdown group 0", "type": "model",
|
||||
"url": os.path.basename(args["countdown_0_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 0,
|
||||
}, {
|
||||
"id": "traffic-countdown-1", "label": "Traffic countdown group 1", "type": "model",
|
||||
"url": os.path.basename(args["countdown_1_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 1,
|
||||
}] + [{
|
||||
"id": asset["id"],
|
||||
"label": asset["label"],
|
||||
|
||||
@@ -59,6 +59,7 @@ from osmassets import grass as _grass # noqa: E402
|
||||
from osmassets import roads as _roads # noqa: E402
|
||||
from osmassets import scrub as _scrub # noqa: E402
|
||||
from osmassets import tree as _tree # noqa: E402
|
||||
from osmassets import traffic_signals as _traffic_signals # noqa: E402
|
||||
|
||||
|
||||
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
|
||||
@@ -638,6 +639,7 @@ def build(args):
|
||||
roads_c = new_collection("03_Roads")
|
||||
buildings_c = new_collection("04_Buildings")
|
||||
props_c = new_collection("05_Props")
|
||||
traffic_dynamic_c = new_collection("06_TrafficSignalsDynamic")
|
||||
|
||||
ground_mat = material_from_spec(catalog.MATERIALS["ground"])
|
||||
water_mat = material_from_spec(catalog.MATERIALS["water"])
|
||||
@@ -661,6 +663,25 @@ def build(args):
|
||||
layer["id"]: material_from_spec(spec)
|
||||
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
|
||||
}
|
||||
traffic_signal_mats = {
|
||||
"metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]),
|
||||
"housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]),
|
||||
"lenses": {
|
||||
"red": material_from_spec(catalog.MATERIALS["traffic_signal_red"]),
|
||||
"yellow": material_from_spec(catalog.MATERIALS["traffic_signal_yellow"]),
|
||||
"green": material_from_spec(catalog.MATERIALS["traffic_signal_green"]),
|
||||
},
|
||||
"active": material_from_spec(catalog.MATERIALS["traffic_signal_active_green"]),
|
||||
"dynamic": {
|
||||
state: material_from_spec(catalog.MATERIALS["traffic_signal_active_" + state])
|
||||
for state in ("red", "yellow", "green")
|
||||
},
|
||||
}
|
||||
traffic_signal_mats["dynamic"]["countdown"] = {}
|
||||
for phase_group in (0, 1):
|
||||
material = traffic_signal_mats["dynamic"]["green"].copy()
|
||||
material.name = "Traffic Signal Countdown Group %d" % phase_group
|
||||
traffic_signal_mats["dynamic"]["countdown"][phase_group] = material
|
||||
|
||||
b = bounds
|
||||
scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"]))
|
||||
@@ -690,6 +711,7 @@ def build(args):
|
||||
"scrub_bush_count": 0,
|
||||
"scrub_count": 0,
|
||||
"scrub_tree_count": 0,
|
||||
"traffic_signal_count": 0,
|
||||
}
|
||||
|
||||
def add_scrub_patch_with_bushes(name, ring, ground_material, collection):
|
||||
@@ -781,6 +803,22 @@ def build(args):
|
||||
_roads.assemble_osm_fallback(
|
||||
ways, projector, roads_c, road_mats["road_surface"])
|
||||
|
||||
traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json")
|
||||
dynamic_signal_objects = 0
|
||||
if os.path.exists(traffic_signal_path):
|
||||
try:
|
||||
with open(traffic_signal_path, "r", encoding="utf-8") as handle:
|
||||
signal_data = json.load(handle)
|
||||
counts["traffic_signal_count"] = _traffic_signals.assemble(
|
||||
signal_data, projector, props_c, traffic_signal_mats)
|
||||
dynamic_signal_objects = len(_traffic_signals.assemble_dynamic(
|
||||
signal_data, projector, traffic_dynamic_c,
|
||||
traffic_signal_mats["dynamic"]))
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
print("Traffic signal warning:", error)
|
||||
if dynamic_signal_objects == 0:
|
||||
raise RuntimeError("Traffic signal dynamic geometry failed") from error
|
||||
|
||||
trees = []
|
||||
individual_tree_count = 0
|
||||
for feature in point_features:
|
||||
@@ -883,6 +921,7 @@ def build(args):
|
||||
scene["scrub_bush_count"] = counts["scrub_bush_count"]
|
||||
scene["scrub_tree_count"] = counts["scrub_tree_count"]
|
||||
scene["fountain_count"] = counts["fountain_count"]
|
||||
scene["traffic_signal_count"] = counts["traffic_signal_count"]
|
||||
scene["tree_node_count"] = individual_tree_count
|
||||
scene["tree_row_count"] = row_tree_count
|
||||
scene["tree_count"] = len(trees)
|
||||
@@ -914,6 +953,8 @@ def build(args):
|
||||
"scrub_bushes": counts["scrub_bush_count"],
|
||||
"scrub_trees": counts["scrub_tree_count"],
|
||||
"fountains": counts["fountain_count"],
|
||||
"traffic_signals": counts["traffic_signal_count"],
|
||||
"traffic_signal_dynamic_objects": dynamic_signal_objects,
|
||||
"tree_nodes": individual_tree_count,
|
||||
"tree_row_instances": row_tree_count,
|
||||
"trees": len(trees),
|
||||
|
||||
@@ -152,6 +152,41 @@ MATERIALS = {
|
||||
"cesium": {"tint": None,
|
||||
"base_color": (0.11, 0.34, 0.075),
|
||||
"emission": ((0.04, 0.11, 0.035), 0.02)}},
|
||||
"traffic_signal_metal": {"kind": "solid", "name": "Traffic Signal Metal",
|
||||
"color": (0.045, 0.065, 0.075), "roughness": 0.42,
|
||||
"metallic": 0.62,
|
||||
"cesium": {"base_color": (0.12, 0.16, 0.18),
|
||||
"metallic": 0.42,
|
||||
"emission": ((0.035, 0.05, 0.06), 0.03)}},
|
||||
"traffic_signal_housing": {"kind": "solid", "name": "Traffic Signal Housing",
|
||||
"color": (0.02, 0.03, 0.035), "roughness": 0.54,
|
||||
"metallic": 0.12,
|
||||
"cesium": {"base_color": (0.055, 0.075, 0.085),
|
||||
"emission": ((0.018, 0.025, 0.03), 0.025)}},
|
||||
# Static lenses are intentionally neutral and dark. The separate dynamic
|
||||
# GLB is the sole source of phase colour, so inactive red/yellow/green
|
||||
# glass cannot visually mask an otherwise working phase transition.
|
||||
"traffic_signal_red": {"kind": "solid", "name": "Traffic Signal Red Lens",
|
||||
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
||||
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
||||
"traffic_signal_yellow": {"kind": "solid", "name": "Traffic Signal Yellow Lens",
|
||||
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
||||
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
||||
"traffic_signal_green": {"kind": "solid", "name": "Traffic Signal Green Lens",
|
||||
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
||||
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
||||
"traffic_signal_active_red": {"kind": "solid", "name": "Traffic Signal Active Red",
|
||||
"color": (0.93, 0.05, 0.035), "roughness": 0.25,
|
||||
"cesium": {"base_color": (0.93, 0.05, 0.035),
|
||||
"emission": ((0.93, 0.05, 0.035), 1.0)}},
|
||||
"traffic_signal_active_yellow": {"kind": "solid", "name": "Traffic Signal Active Yellow",
|
||||
"color": (0.98, 0.63, 0.03), "roughness": 0.25,
|
||||
"cesium": {"base_color": (0.98, 0.63, 0.03),
|
||||
"emission": ((0.98, 0.63, 0.03), 1.0)}},
|
||||
"traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green",
|
||||
"color": (0.04, 0.82, 0.22), "roughness": 0.25,
|
||||
"cesium": {"base_color": (0.04, 0.82, 0.22),
|
||||
"emission": ((0.04, 0.82, 0.22), 1.0)}},
|
||||
}
|
||||
|
||||
|
||||
|
||||
369
blender/osmassets/traffic_signals.py
Normal file
369
blender/osmassets/traffic_signals.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Static traffic-signal geometry for the main Blender scene.
|
||||
|
||||
The anchor file is generated by the intermediates stage. Cesium consumes the
|
||||
same anchors for its dynamic lenses and countdown digits, so this module only
|
||||
creates the durable structure around them.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
|
||||
from osmassets.mesh import MeshBatch
|
||||
|
||||
|
||||
DEFAULT_LAYOUT = {
|
||||
"poleHeightMeters": 6.7,
|
||||
"poleRadiusMeters": 0.13,
|
||||
"armWidthMeters": 0.21,
|
||||
"mastHeightMeters": 6.25,
|
||||
"headCenterHeightMeters": 6.25,
|
||||
"headWidthMeters": 0.68,
|
||||
"headDepthMeters": 0.30,
|
||||
"headBodyHeightMeters": 1.62,
|
||||
"lensRadiusMeters": 0.22,
|
||||
"lensDepthMeters": 0.07,
|
||||
"lensFaceOffsetMeters": 0.18,
|
||||
"lensVerticalOffsetsMeters": [0.49, -0.01, -0.51],
|
||||
"countdownLateralMeters": 1.15,
|
||||
"countdownFaceOffsetMeters": 0.05,
|
||||
"countdownWidthMeters": 0.82,
|
||||
"countdownDepthMeters": 0.14,
|
||||
"countdownHeightMeters": 0.56,
|
||||
"countdownVerticalOffsetMeters": 0.0,
|
||||
}
|
||||
|
||||
COUNTDOWN_VALUES = tuple("%02d" % value for value in range(20))
|
||||
COUNTDOWN_FONT_PATH = os.path.normpath(os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "assets", "fonts", "7LED-1.ttf"))
|
||||
|
||||
|
||||
def assemble(signal_data, projector, collection, materials):
|
||||
"""Add batched static signal structures and return the accepted count."""
|
||||
metal = MeshBatch("Traffic Signal Metal", collection, materials["metal"])
|
||||
housing = MeshBatch("Traffic Signal Housing", collection, materials["housing"])
|
||||
lenses = {
|
||||
state: MeshBatch("Traffic Signal %s Lens" % state.title(), collection, material)
|
||||
for state, material in materials["lenses"].items()
|
||||
}
|
||||
layout = _layout(signal_data.get("layout"))
|
||||
count = 0
|
||||
for signal in signal_data.get("signals", []):
|
||||
if not _valid_signal(signal):
|
||||
continue
|
||||
pose = signal.get("pose") if _valid_pose(signal.get("pose")) else None
|
||||
if pose:
|
||||
x, y = projector.xy((pose["pole"]["longitude"], pose["pole"]["latitude"]))
|
||||
head_x, head_y = projector.xy((pose["head"]["longitude"], pose["head"]["latitude"]))
|
||||
face_heading = math.radians(pose["head"]["faceHeadingDegrees"])
|
||||
face = (math.sin(face_heading), math.cos(face_heading))
|
||||
lateral = (-math.cos(face_heading), math.sin(face_heading))
|
||||
else:
|
||||
x, y = projector.xy((signal["longitude"], signal["latitude"]))
|
||||
heading = math.radians(signal["headingDegrees"])
|
||||
longitudinal = (math.sin(heading), math.cos(heading))
|
||||
lateral = (math.cos(heading), -math.sin(heading))
|
||||
face = (-longitudinal[0], -longitudinal[1])
|
||||
mast_reach = float(signal.get("mastReachMeters") or 4.5)
|
||||
head_x, head_y = _offset(x, y, lateral, -mast_reach)
|
||||
|
||||
_add_cylinder(metal, x, y, layout["poleHeightMeters"] / 2,
|
||||
layout["poleRadiusMeters"], layout["poleHeightMeters"])
|
||||
_add_box(metal, (x, y), (head_x, head_y), layout["armWidthMeters"] / 2,
|
||||
layout["mastHeightMeters"] - layout["armWidthMeters"] / 2,
|
||||
layout["armWidthMeters"])
|
||||
_add_oriented_box(
|
||||
housing, head_x, head_y, lateral, face,
|
||||
layout["headWidthMeters"], layout["headDepthMeters"],
|
||||
layout["headCenterHeightMeters"],
|
||||
layout["headBodyHeightMeters"],
|
||||
)
|
||||
for index, state in enumerate(("red", "yellow", "green")):
|
||||
if pose:
|
||||
lens_x, lens_y = projector.xy((pose["lenses"][index]["longitude"], pose["lenses"][index]["latitude"]))
|
||||
lens_z = pose["lenses"][index]["height"]
|
||||
else:
|
||||
lens_x, lens_y = _offset(head_x, head_y, face, layout["lensFaceOffsetMeters"])
|
||||
lens_z = layout["headCenterHeightMeters"] + layout["lensVerticalOffsetsMeters"][index]
|
||||
_add_lens(
|
||||
lenses[state], lens_x, lens_y, lens_z,
|
||||
lateral, face, layout["lensRadiusMeters"], layout["lensDepthMeters"], 10,
|
||||
)
|
||||
if pose:
|
||||
board_x, board_y = projector.xy((pose["countdown"]["longitude"], pose["countdown"]["latitude"]))
|
||||
board_z = pose["countdown"]["height"]
|
||||
else:
|
||||
board_x, board_y = _offset(head_x, head_y, lateral, layout["countdownLateralMeters"])
|
||||
board_x, board_y = _offset(board_x, board_y, face, layout["countdownFaceOffsetMeters"])
|
||||
board_z = layout["mastHeightMeters"] + layout["countdownVerticalOffsetMeters"]
|
||||
_add_oriented_box(housing, board_x, board_y, lateral, face,
|
||||
layout["countdownWidthMeters"], layout["countdownDepthMeters"],
|
||||
board_z,
|
||||
layout["countdownHeightMeters"])
|
||||
count += 1
|
||||
|
||||
metal.finish()
|
||||
housing.finish()
|
||||
for batch in lenses.values():
|
||||
batch.finish()
|
||||
return count
|
||||
|
||||
|
||||
def assemble_dynamic(signal_data, projector, collection, materials):
|
||||
"""Build phase meshes plus instanced font countdowns for Cesium."""
|
||||
layout = _layout(signal_data.get("layout"))
|
||||
objects = []
|
||||
countdown_materials = materials.get("countdown") or {}
|
||||
if not countdown_materials:
|
||||
raise RuntimeError("Traffic signal countdown material is not configured")
|
||||
countdown_meshes = _countdown_meshes(countdown_materials)
|
||||
for signal in signal_data.get("signals", []):
|
||||
if not _valid_signal(signal) or not _valid_pose(signal.get("pose")):
|
||||
continue
|
||||
pose = signal["pose"]
|
||||
face_heading = math.radians(pose["head"]["faceHeadingDegrees"])
|
||||
face = (math.sin(face_heading), math.cos(face_heading))
|
||||
lateral = (-math.cos(face_heading), math.sin(face_heading))
|
||||
# The static lenses already occupy the head face. Dynamic emissive
|
||||
# covers must sit just in front of them or the static material wins the
|
||||
# depth test and masks every phase change.
|
||||
active_lens_depth = min(0.025, layout["lensDepthMeters"])
|
||||
active_lens_radius = layout["lensRadiusMeters"] * 0.88
|
||||
active_lens_offset = (layout["lensDepthMeters"] + active_lens_depth) / 2 + 0.003
|
||||
for state in ("red", "yellow", "green"):
|
||||
batch = MeshBatch("TrafficSignalDynamic_%s_%s" % (signal["id"], state), collection, materials[state])
|
||||
for index in (0, 1, 2):
|
||||
point = pose["lenses"][index]
|
||||
if point["state"] == state:
|
||||
x, y = projector.xy((point["longitude"], point["latitude"]))
|
||||
x, y = _offset(x, y, face, active_lens_offset)
|
||||
_add_lens(batch, x, y, point["height"], lateral, face,
|
||||
active_lens_radius, active_lens_depth, 10)
|
||||
obj = batch.finish()
|
||||
if obj:
|
||||
objects.append(obj)
|
||||
board = pose["countdown"]
|
||||
board_x, board_y = projector.xy((board["longitude"], board["latitude"]))
|
||||
board_z = board["height"]
|
||||
text_x, text_y = _offset(
|
||||
board_x, board_y, face, layout["countdownDepthMeters"] / 2 + 0.008)
|
||||
phase_group = int(signal.get("phaseGroup") or 0) % 2
|
||||
for value, mesh in countdown_meshes[phase_group].items():
|
||||
objects.append(_countdown_instance(
|
||||
"TrafficSignalDynamic_%s_countdown_%s" % (signal["id"], value),
|
||||
mesh, collection, text_x, text_y, board_z, lateral, face))
|
||||
return objects
|
||||
|
||||
|
||||
def _countdown_meshes(materials):
|
||||
"""Create 20 inverted font meshes per phase group, shared by all signals."""
|
||||
try:
|
||||
import bpy
|
||||
except ImportError:
|
||||
# Geometry unit tests run in CPython without Blender. Their lens checks
|
||||
# remain useful while the actual font conversion is Blender-only.
|
||||
return {group: {} for group in materials}
|
||||
if not os.path.exists(COUNTDOWN_FONT_PATH):
|
||||
raise RuntimeError("Traffic signal countdown font not found: %s" % COUNTDOWN_FONT_PATH)
|
||||
font = bpy.data.fonts.load(COUNTDOWN_FONT_PATH, check_existing=True)
|
||||
meshes = {group: {} for group in materials}
|
||||
for group, material in materials.items():
|
||||
for value in COUNTDOWN_VALUES:
|
||||
meshes[group][value] = _inverted_countdown_mesh(value, group, font, material)
|
||||
return meshes
|
||||
|
||||
|
||||
def _inverted_countdown_mesh(value, group, font, material):
|
||||
"""Turn 7LED's dark glyph cut-out into the emissive number geometry."""
|
||||
import bpy
|
||||
|
||||
curve = bpy.data.curves.new("TrafficSignalCountdown_%s_%s" % (group, value), "FONT")
|
||||
curve.body = value
|
||||
curve.font = font
|
||||
curve.align_x = "CENTER"
|
||||
curve.align_y = "CENTER"
|
||||
curve.size = 0.44
|
||||
curve.extrude = 0.004
|
||||
curve.resolution_u = 1
|
||||
text = bpy.data.objects.new("TrafficSignalCountdownTemplate_%s_%s" % (group, value), curve)
|
||||
bpy.context.scene.collection.objects.link(text)
|
||||
bpy.context.view_layer.objects.active = text
|
||||
text.select_set(True)
|
||||
bpy.ops.object.convert(target="CURVE")
|
||||
glyph = bpy.context.view_layer.objects.active
|
||||
vertices = []
|
||||
faces = []
|
||||
depth = 0.008
|
||||
for spline in glyph.data.splines:
|
||||
if _spline_area(spline) >= 0:
|
||||
continue
|
||||
loop = _sample_bezier_loop(spline)
|
||||
if len(loop) < 3:
|
||||
continue
|
||||
start = len(vertices)
|
||||
vertices.extend((x, y, -depth / 2) for x, y in loop)
|
||||
vertices.extend((x, y, depth / 2) for x, y in loop)
|
||||
count = len(loop)
|
||||
faces.append(tuple(reversed(range(start, start + count))))
|
||||
faces.append(tuple(range(start + count, start + count * 2)))
|
||||
for index in range(count):
|
||||
next_index = (index + 1) % count
|
||||
faces.append((start + index, start + next_index,
|
||||
start + count + next_index, start + count + index))
|
||||
if not vertices:
|
||||
raise RuntimeError("7LED font contains no digit cut-outs for %s" % value)
|
||||
mesh = bpy.data.meshes.new("TrafficSignalCountdownMesh_%s_%s" % (group, value))
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
mesh.materials.append(material)
|
||||
mesh.update()
|
||||
mesh.name = "TrafficSignalCountdownMesh_%s_%s" % (group, value)
|
||||
bpy.data.objects.remove(glyph, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def _spline_area(spline):
|
||||
if spline.type != "BEZIER" or len(spline.bezier_points) < 3:
|
||||
return 0
|
||||
points = spline.bezier_points
|
||||
return sum(
|
||||
point.co.x * points[(index + 1) % len(points)].co.y -
|
||||
points[(index + 1) % len(points)].co.x * point.co.y
|
||||
for index, point in enumerate(points)
|
||||
) / 2
|
||||
|
||||
|
||||
def _sample_bezier_loop(spline, samples_per_edge=8):
|
||||
points = spline.bezier_points
|
||||
result = []
|
||||
for index, start in enumerate(points):
|
||||
end = points[(index + 1) % len(points)]
|
||||
p0 = start.co
|
||||
p1 = start.handle_right
|
||||
p2 = end.handle_left
|
||||
p3 = end.co
|
||||
for step in range(samples_per_edge):
|
||||
t = step / samples_per_edge
|
||||
inverse = 1 - t
|
||||
result.append((
|
||||
inverse ** 3 * p0.x + 3 * inverse ** 2 * t * p1.x +
|
||||
3 * inverse * t ** 2 * p2.x + t ** 3 * p3.x,
|
||||
inverse ** 3 * p0.y + 3 * inverse ** 2 * t * p1.y +
|
||||
3 * inverse * t ** 2 * p2.y + t ** 3 * p3.y,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _countdown_instance(name, mesh, collection, x, y, z, across, face):
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
collection.objects.link(obj)
|
||||
# Text geometry starts in the local XY plane. Map X across the board, Y
|
||||
# upward, and its front normal toward the same approach-facing axis as the
|
||||
# static housing and dynamic lenses.
|
||||
obj.matrix_world = Matrix(((
|
||||
(across[0], 0.0, face[0], x),
|
||||
(across[1], 0.0, face[1], y),
|
||||
(0.0, 1.0, 0.0, z),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)))
|
||||
return obj
|
||||
|
||||
|
||||
def _valid_signal(signal):
|
||||
if not isinstance(signal, dict):
|
||||
return False
|
||||
try:
|
||||
return all(math.isfinite(float(signal.get(key)))
|
||||
for key in ("longitude", "latitude", "headingDegrees"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _valid_pose(pose):
|
||||
try:
|
||||
return (isinstance(pose, dict) and len(pose.get("lenses", [])) == 3
|
||||
and all(math.isfinite(float(pose[key]["longitude"]))
|
||||
and math.isfinite(float(pose[key]["latitude"]))
|
||||
for key in ("pole", "head", "countdown")))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _layout(value):
|
||||
layout = dict(DEFAULT_LAYOUT)
|
||||
if not isinstance(value, dict):
|
||||
return layout
|
||||
for key, default in DEFAULT_LAYOUT.items():
|
||||
candidate = value.get(key)
|
||||
if isinstance(default, list):
|
||||
if (isinstance(candidate, list) and len(candidate) == len(default)
|
||||
and all(isinstance(item, (int, float)) and math.isfinite(item)
|
||||
for item in candidate)):
|
||||
layout[key] = candidate
|
||||
elif (isinstance(candidate, (int, float)) and math.isfinite(candidate)
|
||||
and (key == "countdownVerticalOffsetMeters" or candidate > 0)):
|
||||
layout[key] = candidate
|
||||
return layout
|
||||
|
||||
|
||||
def _offset(x, y, direction, distance):
|
||||
return x + direction[0] * distance, y + direction[1] * distance
|
||||
|
||||
|
||||
def _add_box(batch, start, end, width, base, height):
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length <= 0:
|
||||
return
|
||||
across = (-dy / length, dx / length)
|
||||
half = width / 2
|
||||
ring = [
|
||||
(start[0] + across[0] * half, start[1] + across[1] * half),
|
||||
(end[0] + across[0] * half, end[1] + across[1] * half),
|
||||
(end[0] - across[0] * half, end[1] - across[1] * half),
|
||||
(start[0] - across[0] * half, start[1] - across[1] * half),
|
||||
]
|
||||
batch.add_prism(ring, base, height)
|
||||
|
||||
|
||||
def _add_oriented_box(batch, x, y, across, depth, width, thickness, center_z, height):
|
||||
half_width = width / 2
|
||||
half_depth = thickness / 2
|
||||
ring = [
|
||||
(x + across[0] * sx * half_width + depth[0] * sy * half_depth,
|
||||
y + across[1] * sx * half_width + depth[1] * sy * half_depth)
|
||||
for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1))
|
||||
]
|
||||
batch.add_prism(ring, center_z - height / 2, height)
|
||||
|
||||
|
||||
def _add_cylinder(batch, x, y, center_z, radius, height, sides=8):
|
||||
ring = [
|
||||
(x + math.cos(math.tau * index / sides) * radius,
|
||||
y + math.sin(math.tau * index / sides) * radius)
|
||||
for index in range(sides)
|
||||
]
|
||||
batch.add_prism(ring, center_z - height / 2, height)
|
||||
|
||||
|
||||
def _add_lens(batch, x, y, z, across, face, radius, depth, sides):
|
||||
"""Add a shallow round lens flush with the head's approach-facing surface."""
|
||||
start = len(batch.vertices)
|
||||
for face_offset in (-depth / 2, depth / 2):
|
||||
for index in range(sides):
|
||||
theta = math.tau * index / sides
|
||||
batch.vertices.append((
|
||||
x + face[0] * face_offset + across[0] * math.cos(theta) * radius,
|
||||
y + face[1] * face_offset + across[1] * math.cos(theta) * radius,
|
||||
z + math.sin(theta) * radius,
|
||||
))
|
||||
batch.faces.append(tuple(range(start, start + sides)))
|
||||
batch.faces.append(tuple(range(start + sides, start + sides * 2)))
|
||||
for index in range(sides):
|
||||
next_index = (index + 1) % sides
|
||||
a = start + index
|
||||
b = start + next_index
|
||||
c = start + sides + next_index
|
||||
d = start + sides + index
|
||||
batch.faces.append((a, b, c, d))
|
||||
124
blender/tests/test_traffic_signals.py
Normal file
124
blender/tests/test_traffic_signals.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Static traffic-signal geometry can be exercised without Blender itself."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
|
||||
|
||||
class FakeBatch:
|
||||
created = []
|
||||
|
||||
def __init__(self, name, collection, material):
|
||||
self.name = name
|
||||
self.vertices = []
|
||||
self.faces = []
|
||||
FakeBatch.created.append(self)
|
||||
|
||||
def add_prism(self, ring, base, height):
|
||||
if len(ring) < 3:
|
||||
return
|
||||
start = len(self.vertices)
|
||||
self.vertices.extend((x, y, base) for x, y in ring)
|
||||
self.vertices.extend((x, y, base + height) for x, y in ring)
|
||||
size = len(ring)
|
||||
self.faces.extend((tuple(range(start, start + size)),
|
||||
tuple(range(start + size, start + size * 2))))
|
||||
|
||||
def finish(self):
|
||||
return self.vertices or None
|
||||
|
||||
|
||||
class Projector:
|
||||
def xy(self, point):
|
||||
return point
|
||||
|
||||
|
||||
class TrafficSignalGeometryTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
mesh = types.ModuleType("osmassets.mesh")
|
||||
mesh.MeshBatch = FakeBatch
|
||||
cls.previous_mesh = sys.modules.get("osmassets.mesh")
|
||||
sys.modules["osmassets.mesh"] = mesh
|
||||
sys.modules.pop("osmassets.traffic_signals", None)
|
||||
cls.signals = importlib.import_module("osmassets.traffic_signals")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
sys.modules.pop("osmassets.traffic_signals", None)
|
||||
if cls.previous_mesh is None:
|
||||
sys.modules.pop("osmassets.mesh", None)
|
||||
else:
|
||||
sys.modules["osmassets.mesh"] = cls.previous_mesh
|
||||
|
||||
def test_valid_anchor_builds_static_geometry_on_the_driver_right(self):
|
||||
FakeBatch.created = []
|
||||
count = self.signals.assemble({
|
||||
"layout": {"countdownLateralMeters": 1.15},
|
||||
"signals": [{
|
||||
"longitude": 10.0,
|
||||
"latitude": 20.0,
|
||||
"headingDegrees": 0.0,
|
||||
"mastReachMeters": 4.5,
|
||||
}],
|
||||
}, Projector(), object(), {
|
||||
"metal": object(),
|
||||
"housing": object(),
|
||||
"lenses": {"red": object(), "yellow": object(), "green": object()},
|
||||
})
|
||||
self.assertEqual(count, 1)
|
||||
housing = next(batch for batch in FakeBatch.created
|
||||
if batch.name == "Traffic Signal Housing")
|
||||
# A northbound driver's right is east, so the board's vertices must
|
||||
# extend east of the mast-reached head at longitude 5.5.
|
||||
self.assertGreater(max(vertex[0] for vertex in housing.vertices), 6.5)
|
||||
red_lens = next(batch for batch in FakeBatch.created
|
||||
if batch.name == "Traffic Signal Red Lens")
|
||||
# The mast arm and the head share z=6.25. The red lens sits inside
|
||||
# the top half of the 1.62m head rather than above its centre line.
|
||||
self.assertLessEqual(max(vertex[2] for vertex in red_lens.vertices), 6.98)
|
||||
|
||||
def test_missing_anchor_coordinate_is_skipped(self):
|
||||
FakeBatch.created = []
|
||||
count = self.signals.assemble({"signals": [{"longitude": 10.0}]}, Projector(),
|
||||
object(), {"metal": object(), "housing": object(),
|
||||
"lenses": {"red": object(), "yellow": object(), "green": object()}})
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_dynamic_lens_geometry_is_in_front_of_static_lens_face(self):
|
||||
FakeBatch.created = []
|
||||
signal = {
|
||||
"id": "signal-1", "longitude": 10.0, "latitude": 20.0,
|
||||
"headingDegrees": 0.0,
|
||||
"pose": {
|
||||
"pole": {"longitude": 10.0, "latitude": 20.0},
|
||||
"head": {"longitude": 10.0, "latitude": 20.0,
|
||||
"faceHeadingDegrees": 0.0},
|
||||
"lenses": [{"state": state, "longitude": 10.0,
|
||||
"latitude": 20.0, "height": 6.25}
|
||||
for state in ("red", "yellow", "green")],
|
||||
"countdown": {"longitude": 10.0, "latitude": 20.0,
|
||||
"height": 6.25},
|
||||
},
|
||||
}
|
||||
self.signals.assemble_dynamic({"signals": [signal]}, Projector(), object(), {
|
||||
"red": object(), "yellow": object(), "green": object(), "active": object(),
|
||||
"countdown": {0: object(), 1: object()},
|
||||
})
|
||||
red = next(batch for batch in FakeBatch.created
|
||||
if batch.name == "TrafficSignalDynamic_signal-1_red")
|
||||
# Facing north, every active overlay vertex must sit north of the
|
||||
# static lens centre rather than intersecting its body.
|
||||
self.assertGreater(min(vertex[1] for vertex in red.vertices), 20.035)
|
||||
|
||||
def test_countdown_uses_the_versioned_font_and_twenty_shared_values(self):
|
||||
self.assertTrue(os.path.exists(self.signals.COUNTDOWN_FONT_PATH))
|
||||
self.assertEqual(self.signals.COUNTDOWN_VALUES, tuple("%02d" % value for value in range(20)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
const { digest: glbDigest } = require("./glb-digest");
|
||||
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
||||
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
||||
const { readTrafficSignals } = require("./lib/traffic-signals");
|
||||
const {
|
||||
cesiumPreviewHtml,
|
||||
previewSummary,
|
||||
@@ -181,6 +182,7 @@ function buildIntermediates(area) {
|
||||
"--config",
|
||||
derivedConfigPath,
|
||||
], "intermediates");
|
||||
writeTrafficSignals(area);
|
||||
fs.rmSync(stageManifestPath(area, "reimport"), { force: true });
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -198,6 +200,7 @@ function buildIntermediates(area) {
|
||||
derivedConfig: fileRecord(derivedConfigPath),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
gpkg: fileRecord(area.outputs.gpkg),
|
||||
qgisProject: fileRecord(area.outputs.qgisProject),
|
||||
qgisPreview: optionalFileRecord(area.outputs.qgisPreview),
|
||||
@@ -220,6 +223,7 @@ function reimportGpkg(area) {
|
||||
"--config",
|
||||
derivedConfigPath,
|
||||
], "reimport");
|
||||
writeTrafficSignals(area);
|
||||
fs.rmSync(stageManifestPath(area, "intermediates"), { force: true });
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -237,6 +241,7 @@ function reimportGpkg(area) {
|
||||
outputs: {
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
summary: {
|
||||
geojson: geojsonFeatureCounts(area),
|
||||
@@ -248,6 +253,7 @@ function reimportGpkg(area) {
|
||||
function buildBlenderScene(area) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
|
||||
|
||||
@@ -289,6 +295,7 @@ function buildBlenderScene(area) {
|
||||
osm: fileRecord(area.input),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
outputs: {
|
||||
blend: fileRecord(area.outputs.blend),
|
||||
@@ -323,9 +330,18 @@ function exportCesium(area) {
|
||||
area.outputs.blend,
|
||||
"--glb",
|
||||
area.outputs.glb,
|
||||
"--dynamic-glb",
|
||||
area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb",
|
||||
area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb",
|
||||
area.outputs.trafficSignalsCountdown1Glb,
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
], "cesium");
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
const semanticAssets = semanticAssetRecords(area);
|
||||
writeCesiumPreview(area);
|
||||
const finished = Date.now();
|
||||
@@ -343,6 +359,7 @@ function exportCesium(area) {
|
||||
outputs: {
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
semanticAssets,
|
||||
},
|
||||
summary: {
|
||||
@@ -465,6 +482,7 @@ function runCommand(command, commandArgs, stage) {
|
||||
function writeCesiumPreview(area) {
|
||||
ensureFile(area.outputs.glb, "Cesium GLB");
|
||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
const htmlPath = area.outputs.cesiumPreview;
|
||||
const started = Date.now();
|
||||
const startedAt = new Date(started).toISOString();
|
||||
@@ -476,7 +494,7 @@ function writeCesiumPreview(area) {
|
||||
const metadataName = path.basename(area.outputs.metadata);
|
||||
const routeName = path.basename(area.outputs.vehicleRoute);
|
||||
const vehicleModelName = path.basename(area.outputs.vehicleModel);
|
||||
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames));
|
||||
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals)));
|
||||
console.log(`Cesium preview: ${htmlPath}`);
|
||||
const finished = Date.now();
|
||||
writeStageManifest(area, {
|
||||
@@ -498,12 +516,26 @@ function writeCesiumPreview(area) {
|
||||
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
|
||||
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
|
||||
vehicleModel: fileRecord(area.outputs.vehicleModel),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
summary: previewSummary(area),
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
|
||||
function writeTrafficSignals(area) {
|
||||
const signals = readTrafficSignals(
|
||||
path.join(area.outputs.geojsonDir, "vehicle_stop_lines.geojson"),
|
||||
path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
);
|
||||
fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`);
|
||||
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
|
||||
}
|
||||
|
||||
function previewRelativePath(fromDir, target) {
|
||||
return path.relative(fromDir, target).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function writeVehicleRoute(area) {
|
||||
const route = buildPreviewVehicleRoute(area.input);
|
||||
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
|
||||
|
||||
@@ -28,15 +28,21 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
const compressedFileStem = outputOverrides.compressedFileStem ||
|
||||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
|
||||
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
|
||||
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
|
||||
const outputs = {
|
||||
areaDir,
|
||||
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
|
||||
geojsonDir,
|
||||
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
||||
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
|
||||
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
|
||||
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
|
||||
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
|
||||
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
|
||||
trafficSignalsDynamicGlb: path.resolve(
|
||||
outputOverrides.trafficSignalsDynamicGlb || path.join(areaDir, `${fileStem}-traffic-signals-dynamic.glb`),
|
||||
),
|
||||
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
|
||||
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
|
||||
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
|
||||
cesiumPreview: path.resolve(
|
||||
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
|
||||
@@ -52,6 +58,9 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
),
|
||||
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
|
||||
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
|
||||
// Signals are an auxiliary intermediates artifact shared by Blender and
|
||||
// the browser preview. They deliberately are not one of the QGIS layers.
|
||||
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(geojsonDir, "traffic_signals.json")),
|
||||
pipelineDir,
|
||||
stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")),
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ function writeCesiumPreviewSupportFiles(outDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = []) {
|
||||
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) {
|
||||
const previewConfig = {
|
||||
areaId,
|
||||
glbName,
|
||||
@@ -21,6 +21,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
||||
routeName,
|
||||
vehicleModelName,
|
||||
vehicleModelNames,
|
||||
trafficSignalsName,
|
||||
};
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -53,6 +54,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
||||
<span id="semanticToggles" class="control-subgroup hidden"></span>
|
||||
<label><input id="toggleRoutes" type="checkbox" checked> Routes</label>
|
||||
<label><input id="toggleVehicles" type="checkbox" checked> Vehicles</label>
|
||||
<label id="signalsControl"><input id="toggleSignals" type="checkbox" checked> Signals</label>
|
||||
<label><input id="toggleFps" type="checkbox"> FPS</label>
|
||||
<label><input id="toggleDiagnostics" type="checkbox" checked> Info</label>
|
||||
</div>
|
||||
@@ -106,6 +108,7 @@ function previewSummary(area) {
|
||||
metadataName: path.basename(area.outputs.metadata),
|
||||
routeName: path.basename(area.outputs.vehicleRoute),
|
||||
vehicleModelName: path.basename(area.outputs.vehicleModel),
|
||||
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
const toggleScene = document.getElementById("toggleScene");
|
||||
const toggleRoutes = document.getElementById("toggleRoutes");
|
||||
const toggleVehicles = document.getElementById("toggleVehicles");
|
||||
const toggleSignals = document.getElementById("toggleSignals");
|
||||
const signalsControl = document.getElementById("signalsControl");
|
||||
const toggleFps = document.getElementById("toggleFps");
|
||||
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
|
||||
const assetToggles = document.getElementById("assetToggles");
|
||||
// The exported road surface sits at the 0.35m scene anchor plus 0.03m.
|
||||
// Vehicle models have their wheels at local Y=0, so keep them just clear
|
||||
// of the asphalt instead of using the old visibly floating 1.15m height.
|
||||
const VEHICLE_HEIGHT_METERS = 0.40;
|
||||
const ROUTE_LINE_HEIGHT_METERS = 0.42;
|
||||
const semanticToggles = document.getElementById("semanticToggles");
|
||||
const vehicleSelect = document.getElementById("vehicleSelect");
|
||||
const speedControl = document.getElementById("speedControl");
|
||||
@@ -33,17 +40,20 @@
|
||||
setLoadingMessage("Loading scene", config.areaId || "");
|
||||
const metadata = await fetchJson(config.metadataName);
|
||||
const routeData = await fetchOptionalJson(config.routeName);
|
||||
const signalData = await fetchOptionalJson(config.trafficSignalsName);
|
||||
const placement = scenePlacement(metadata);
|
||||
const viewer = createViewer();
|
||||
setLoadingMessage("Loading model", config.glbName || "");
|
||||
const assets = await loadSceneAssets(viewer, metadata, placement);
|
||||
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName);
|
||||
const trafficStart = Cesium.JulianDate.now();
|
||||
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
|
||||
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
|
||||
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
|
||||
|
||||
buildAssetToggles(assets);
|
||||
buildSemanticToggles(viewer, assets, placement);
|
||||
bindRuntimeControls(viewer, assets, cruise, cameras, placement);
|
||||
startDiagnostics(viewer, metadata, assets, cruise, placement);
|
||||
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
|
||||
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals);
|
||||
cameras.overview();
|
||||
baseStatus = summaryText(metadata, assets, cruise);
|
||||
setStatus(baseStatus);
|
||||
@@ -52,7 +62,7 @@
|
||||
document.body.classList.add("scene-ready");
|
||||
// Handle for the browser console and for headless checks: everything else
|
||||
// in here is closed over by the IIFE and unreachable from outside.
|
||||
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
|
||||
window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras };
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
@@ -272,7 +282,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function bindRuntimeControls(viewer, assets, cruise, cameras, placement) {
|
||||
function bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals) {
|
||||
const hasVehicles = cruise.vehicles.length > 0;
|
||||
const hasSemanticAssets = semanticAssets(assets).length > 0;
|
||||
const sceneLabel = toggleScene.closest("label");
|
||||
@@ -292,6 +302,14 @@
|
||||
toggleVehicles.addEventListener("change", () => {
|
||||
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
|
||||
});
|
||||
if (!trafficSignals.count) {
|
||||
signalsControl.classList.add("hidden");
|
||||
} else {
|
||||
toggleSignals.addEventListener("change", () => {
|
||||
trafficSignals.show = toggleSignals.checked;
|
||||
setStatus(toggleSignals.checked ? "Signals visible" : "Signals hidden");
|
||||
});
|
||||
}
|
||||
toggleFps.addEventListener("change", () => {
|
||||
viewer.scene.debugShowFramesPerSecond = toggleFps.checked;
|
||||
});
|
||||
@@ -422,12 +440,12 @@
|
||||
return stopFollow;
|
||||
}
|
||||
|
||||
function addVehicleCruises(viewer, routeData, vehicleModelNames, fallbackVehicleModelName) {
|
||||
function addVehicleCruises(viewer, routeData, signalData, trafficStart, vehicleModelNames, fallbackVehicleModelName) {
|
||||
const segments = ((routeData && (routeData.routes || routeData.segments)) || [])
|
||||
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
|
||||
.slice(0, 5);
|
||||
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
|
||||
const start = Cesium.JulianDate.now();
|
||||
const start = trafficStart;
|
||||
|
||||
viewer.clock.startTime = start.clone();
|
||||
viewer.clock.currentTime = start.clone();
|
||||
@@ -436,7 +454,7 @@
|
||||
viewer.clock.shouldAnimate = segments.length > 0;
|
||||
|
||||
const vehicles = segments.map((segment, index) => {
|
||||
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed,
|
||||
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
|
||||
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
|
||||
const option = document.createElement("option");
|
||||
option.value = String(index);
|
||||
@@ -451,6 +469,123 @@
|
||||
};
|
||||
}
|
||||
|
||||
function addTrafficSignals(viewer, signalData, start, assets) {
|
||||
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
||||
const countdownModels = new Map(assets
|
||||
.filter((asset) => asset.category === "countdown" && asset.model)
|
||||
.map((asset) => [Number(asset.phaseGroup), asset.model]));
|
||||
if (dynamic && countdownModels.size === 2) {
|
||||
const signals = (signalData?.signals || []).filter((signal) => signal && signal.id);
|
||||
if (signals.length && !viewer.clock.shouldAnimate) viewer.clock.shouldAnimate = true;
|
||||
const visualStart = performance.now();
|
||||
const phaseTime = new Cesium.JulianDate();
|
||||
const state = { elapsedSeconds: 0, phase: "" };
|
||||
const entities = [];
|
||||
const nodes = new Map();
|
||||
const node = (name) => {
|
||||
if (nodes.has(name)) return nodes.get(name);
|
||||
let value = null;
|
||||
try {
|
||||
value = dynamic.model.getNode(name);
|
||||
} catch (error) {
|
||||
console.warn("Traffic signal node unavailable:", name, error);
|
||||
}
|
||||
// Do not cache a miss. Cesium can expose the Model before its node
|
||||
// lookup table is populated; a transient miss must be retried on the
|
||||
// next clock tick rather than freezing the initial visual state.
|
||||
if (value) nodes.set(name, value);
|
||||
return value;
|
||||
};
|
||||
const update = (elapsedSeconds) => {
|
||||
Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
|
||||
let changed = false;
|
||||
const groupPhases = new Map();
|
||||
for (const signal of signals) {
|
||||
const phase = signalPhase(signal.phaseGroup, phaseTime, start);
|
||||
groupPhases.set(signal.phaseGroup, phase.active);
|
||||
if (signal === signals[0]) state.phase = `${phase.active} ${String(phase.remaining).padStart(2, "0")}`;
|
||||
for (const state of ["red", "yellow", "green"]) {
|
||||
const value = node(`TrafficSignalDynamic_${signal.id}_${state}`);
|
||||
if (value && value.show !== (state === phase.active)) {
|
||||
value.show = state === phase.active;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const visibleCountdown = String(phase.remaining).padStart(2, "0");
|
||||
const countdownModel = countdownModels.get(Number(signal.phaseGroup));
|
||||
for (let value = 0; value < 20; value += 1) {
|
||||
const name = `TrafficSignalDynamic_${signal.id}_countdown_${String(value).padStart(2, "0")}`;
|
||||
let countdown = null;
|
||||
try { countdown = countdownModel.getNode(name); } catch (error) { /* model node table is still loading */ }
|
||||
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
|
||||
countdown.show = String(value).padStart(2, "0") === visibleCountdown;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [group, active] of groupPhases) {
|
||||
const countdownModel = countdownModels.get(Number(group));
|
||||
countdownModel.color = signalBaseColor(active);
|
||||
countdownModel.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
||||
countdownModel.colorBlendAmount = 1.0;
|
||||
}
|
||||
if (changed && viewer.scene.requestRender) viewer.scene.requestRender();
|
||||
};
|
||||
let lastSecond = -1;
|
||||
const render = () => {
|
||||
const elapsedSeconds = Math.floor((performance.now() - visualStart) / 1000);
|
||||
if (elapsedSeconds === lastSecond) return;
|
||||
lastSecond = elapsedSeconds;
|
||||
state.elapsedSeconds = elapsedSeconds;
|
||||
update(elapsedSeconds);
|
||||
};
|
||||
// Keep signal phases independent from the Cesium simulation clock. The
|
||||
// clock may be paused while a user inspects the scene, but the lights and
|
||||
// countdown must remain visibly periodic.
|
||||
const timer = setInterval(render, 250);
|
||||
render();
|
||||
return {
|
||||
entities, count: signals.length, dynamic, state, timer,
|
||||
set show(value) {
|
||||
dynamic.model.show = value;
|
||||
for (const model of countdownModels.values()) model.show = value;
|
||||
for (const entity of entities) entity.show = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
return { entities: [], count: 0, set show(value) {} };
|
||||
}
|
||||
|
||||
function signalColor(group, state, time, start) {
|
||||
const active = signalPhase(group, time, start).active;
|
||||
const color = signalBaseColor(state);
|
||||
return state === active ? color : Cesium.Color.multiplyByScalar(color, 0.35, new Cesium.Color());
|
||||
}
|
||||
|
||||
function signalActiveColor(group, time, start) {
|
||||
return signalBaseColor(signalPhase(group, time, start).active);
|
||||
}
|
||||
|
||||
function signalBaseColor(state) {
|
||||
return Cesium.Color.fromCssColorString({ red: "#ee3f39", yellow: "#f7bf37", green: "#43cf71" }[state]);
|
||||
}
|
||||
|
||||
function signalPhase(group, time, start) {
|
||||
return signalPhaseAtElapsed(group, Cesium.JulianDate.secondsDifference(time, start));
|
||||
}
|
||||
|
||||
function signalPhaseAtElapsed(group, elapsed) {
|
||||
const second = ((elapsed % 20) + 20) % 20;
|
||||
if (group === 0) {
|
||||
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
|
||||
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };
|
||||
return { active: "red", remaining: Math.ceil(20 - second) };
|
||||
}
|
||||
if (second < 10) return { active: "red", remaining: Math.ceil(10 - second) };
|
||||
if (second < 18) return { active: "green", remaining: Math.ceil(18 - second) };
|
||||
return { active: "yellow", remaining: Math.ceil(20 - second) };
|
||||
}
|
||||
|
||||
function selectedVehicleModelName(modelNames, fallbackModelName) {
|
||||
const choices = Array.isArray(modelNames) && modelNames.length
|
||||
? modelNames
|
||||
@@ -459,14 +594,13 @@
|
||||
return usable[Math.floor(Math.random() * usable.length)] || "";
|
||||
}
|
||||
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName) {
|
||||
const route = prepareRoute(segment);
|
||||
const positions = new Cesium.CallbackProperty((time, result) => {
|
||||
return routePosition(route, start, time, speed, result);
|
||||
}, false);
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
|
||||
const route = prepareRoute(segment, signalData);
|
||||
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed);
|
||||
const positions = trafficMotion.positions;
|
||||
const flat = [];
|
||||
for (const coord of segment.coordinates) {
|
||||
flat.push(coord[0], coord[1], 1.05);
|
||||
flat.push(coord[0], coord[1], ROUTE_LINE_HEIGHT_METERS);
|
||||
}
|
||||
const routeColor = [
|
||||
Cesium.Color.CYAN,
|
||||
@@ -488,7 +622,7 @@
|
||||
const vehicle = viewer.entities.add({
|
||||
name: "Cruise vehicle " + (index + 1),
|
||||
position: positions,
|
||||
orientation: routeOrientation(route, start, speed, 0.0),
|
||||
orientation: routeOrientationFromState(route, trafficMotion.state, speed, 0.0),
|
||||
model: {
|
||||
uri: vehicleModelName,
|
||||
// Keep the library's world scale visible. A minimum screen size made
|
||||
@@ -525,7 +659,7 @@
|
||||
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
|
||||
}
|
||||
|
||||
function prepareRoute(segment) {
|
||||
function prepareRoute(segment, signalData) {
|
||||
const distances = [0.0];
|
||||
for (let i = 1; i < segment.coordinates.length; i += 1) {
|
||||
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
|
||||
@@ -533,13 +667,63 @@
|
||||
return {
|
||||
coordinates: segment.coordinates,
|
||||
distances,
|
||||
length: Math.max(1.0, distances[distances.length - 1])
|
||||
length: Math.max(1.0, distances[distances.length - 1]),
|
||||
stops: routeStops(segment.coordinates, distances, signalData),
|
||||
};
|
||||
}
|
||||
|
||||
function routeStops(coordinates, distances, signalData) {
|
||||
const found = new Map();
|
||||
for (const signal of signalData?.signals || []) {
|
||||
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) continue;
|
||||
let best = { index: -1, distance: Infinity };
|
||||
for (let index = 0; index < coordinates.length; index += 1) {
|
||||
const distance = distanceMeters(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
|
||||
if (distance < best.distance) best = { index, distance };
|
||||
}
|
||||
if (best.index >= 0 && best.distance <= 7) {
|
||||
const routeDistance = distances[best.index];
|
||||
const existing = found.get(Math.round(routeDistance));
|
||||
if (!existing || best.distance < existing.matchDistance) found.set(Math.round(routeDistance), { distance: routeDistance, matchDistance: best.distance, signal });
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
function createTrafficAwarePositions(viewer, route, start, speed) {
|
||||
const state = { distance: 0, lastTime: start.clone() };
|
||||
viewer.clock.onTick.addEventListener((clock) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime);
|
||||
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
|
||||
if (elapsed <= 0) return;
|
||||
const next = nextRouteStop(route, state.distance);
|
||||
const advance = elapsed * speed;
|
||||
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {
|
||||
const untilStop = (next.distance - state.distance + route.length) % route.length;
|
||||
if (untilStop <= advance + 1.7) {
|
||||
state.distance = (next.distance - 1.7 + route.length) % route.length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
state.distance = (state.distance + advance) % route.length;
|
||||
});
|
||||
return {
|
||||
state,
|
||||
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
|
||||
};
|
||||
}
|
||||
|
||||
function nextRouteStop(route, distance) {
|
||||
return route.stops.find((stop) => stop.distance > distance + 0.05) || route.stops[0] || null;
|
||||
}
|
||||
|
||||
function routePosition(route, start, time, speed, result) {
|
||||
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
|
||||
const distance = (seconds * speed) % route.length;
|
||||
return routePositionAtDistance(route, distance, result);
|
||||
}
|
||||
|
||||
function routePositionAtDistance(route, distance, result) {
|
||||
let index = 1;
|
||||
while (index < route.distances.length - 1 && route.distances[index] < distance) {
|
||||
index += 1;
|
||||
@@ -551,7 +735,7 @@
|
||||
const b = route.coordinates[index];
|
||||
const lon = a[0] + (b[0] - a[0]) * t;
|
||||
const lat = a[1] + (b[1] - a[1]) * t;
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, VEHICLE_HEIGHT_METERS, Cesium.Ellipsoid.WGS84, result);
|
||||
}
|
||||
|
||||
function routeOrientation(route, start, speed, yawDegrees) {
|
||||
@@ -600,6 +784,43 @@
|
||||
}, false);
|
||||
}
|
||||
|
||||
function routeOrientationFromState(route, state, speed, yawDegrees) {
|
||||
const correction = Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
Cesium.Math.toRadians(yawDegrees)
|
||||
);
|
||||
const current = new Cesium.Cartesian3();
|
||||
const ahead = new Cesium.Cartesian3();
|
||||
const direction = new Cesium.Cartesian3();
|
||||
const up = new Cesium.Cartesian3();
|
||||
const east = new Cesium.Cartesian3();
|
||||
const north = new Cesium.Cartesian3();
|
||||
const hpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
||||
const base = new Cesium.Quaternion();
|
||||
return new Cesium.CallbackProperty((time, result) => {
|
||||
routePositionAtDistance(route, state.distance, current);
|
||||
// Sample a small distance ahead, rather than using velocity. This keeps
|
||||
// the car aligned while stopped and preserves the model's route heading.
|
||||
const lookAhead = Math.max(0.8, speed * 0.8);
|
||||
routePositionAtDistance(route, (state.distance + lookAhead) % route.length, ahead);
|
||||
Cesium.Cartesian3.subtract(ahead, current, direction);
|
||||
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) return result;
|
||||
Cesium.Cartesian3.normalize(direction, direction);
|
||||
Cesium.Cartesian3.normalize(current, up);
|
||||
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
|
||||
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
|
||||
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
|
||||
} else {
|
||||
Cesium.Cartesian3.normalize(east, east);
|
||||
}
|
||||
Cesium.Cartesian3.cross(up, east, north);
|
||||
Cesium.Cartesian3.normalize(north, north);
|
||||
hpr.heading = Math.atan2(Cesium.Cartesian3.dot(direction, east), Cesium.Cartesian3.dot(direction, north));
|
||||
Cesium.Transforms.headingPitchRollQuaternion(current, hpr, undefined, undefined, base);
|
||||
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
|
||||
}, false);
|
||||
}
|
||||
|
||||
function createChaseFollow(viewer, positionsProvider) {
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
const scratchPrevious = new Cesium.Cartesian3();
|
||||
@@ -731,7 +952,7 @@
|
||||
|
||||
// Camera-dependent readouts have to track the camera, so refresh off the
|
||||
// render loop rather than a fixed timer, throttled to stay off the hot path.
|
||||
function startDiagnostics(viewer, metadata, assets, cruise, placement) {
|
||||
function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) {
|
||||
const center = placement.position;
|
||||
const stats = metadata.scene_stats || {};
|
||||
const failed = assets.filter((asset) => asset.error);
|
||||
@@ -747,6 +968,7 @@
|
||||
"Camera range: " + Math.round(distance) + " m",
|
||||
"Assets: " + liveAssets(assets).length + " model(s)",
|
||||
"Vehicles: " + cruise.vehicles.length,
|
||||
"Signals: " + trafficSignals.count,
|
||||
"Buildings: " + Number(stats.buildings || 0),
|
||||
"Trees: " + Number(stats.trees || 0),
|
||||
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
|
||||
|
||||
146
scripts/lib/traffic-signals.js
Normal file
146
scripts/lib/traffic-signals.js
Normal file
@@ -0,0 +1,146 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
const MAST_REACH_METERS = 4.5;
|
||||
// This layout is serialized with the anchors so Blender's static structure and
|
||||
// Cesium's dynamic overlay cannot independently drift in size or handedness.
|
||||
// Lateral offsets use the approach travel direction: positive is the driver's
|
||||
// right. The countdown board therefore sits at +1.15m from the signal head.
|
||||
const SIGNAL_LAYOUT = Object.freeze({
|
||||
poleHeightMeters: 6.7,
|
||||
poleRadiusMeters: 0.13,
|
||||
armWidthMeters: 0.21,
|
||||
// The mast arm and the signal head share this centre elevation.
|
||||
mastHeightMeters: 6.25,
|
||||
headCenterHeightMeters: 6.25,
|
||||
headWidthMeters: 0.68,
|
||||
headDepthMeters: 0.30,
|
||||
headBodyHeightMeters: 1.62,
|
||||
lensRadiusMeters: 0.22,
|
||||
lensDepthMeters: 0.07,
|
||||
lensFaceOffsetMeters: 0.18,
|
||||
lensVerticalOffsetsMeters: [0.49, -0.01, -0.51],
|
||||
countdownLateralMeters: 1.15,
|
||||
countdownFaceOffsetMeters: 0.05,
|
||||
countdownWidthMeters: 0.82,
|
||||
countdownDepthMeters: 0.14,
|
||||
countdownHeightMeters: 0.56,
|
||||
// The countdown board is fixed on the mast arm, not hung below it.
|
||||
countdownVerticalOffsetMeters: 0.0,
|
||||
});
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections) {
|
||||
const centers = (intersections.features || []).map((feature, index) => {
|
||||
const point = polygonCenter(feature.geometry);
|
||||
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
|
||||
}).filter((entry) => entry.point);
|
||||
const signals = [];
|
||||
for (const feature of stopLines.features || []) {
|
||||
const center = polygonCenter(feature.geometry);
|
||||
if (!center) continue;
|
||||
const intersection = nearestCenter(center, centers);
|
||||
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
|
||||
const axis = roadAxis(feature.geometry, center, intersection.point);
|
||||
if (!axis) continue;
|
||||
// A vehicle signal belongs beyond the junction, facing back toward the
|
||||
// approaching stop line. Use the far edge of the intersection, never the
|
||||
// near-side stop-line area where it would read as a pedestrian signal.
|
||||
const right = [axis[1], -axis[0]];
|
||||
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
|
||||
// The pole is on the far-side sidewalk, not at the stop line or inside
|
||||
// the intersection. Its mast then reaches back above the approach lanes.
|
||||
const point = moveMeters(farSide, right, CURB_OFFSET_METERS);
|
||||
signals.push({
|
||||
id: `signal-${signals.length + 1}`,
|
||||
intersectionId: intersection.id,
|
||||
phaseGroup: signals.length % 2,
|
||||
longitude: point[0],
|
||||
latitude: point[1],
|
||||
stopLongitude: center[0],
|
||||
stopLatitude: center[1],
|
||||
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
|
||||
});
|
||||
}
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, axis, mastReach) {
|
||||
const lateral = [axis[1], -axis[0]];
|
||||
const face = [-axis[0], -axis[1]];
|
||||
const head = moveMeters(pole, lateral, -mastReach);
|
||||
const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height });
|
||||
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||
const board = moveMeters(
|
||||
moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters),
|
||||
face, SIGNAL_LAYOUT.countdownFaceOffsetMeters,
|
||||
);
|
||||
return {
|
||||
pole: position(pole, 0),
|
||||
arm: {
|
||||
from: position(pole, SIGNAL_LAYOUT.mastHeightMeters),
|
||||
to: position(head, SIGNAL_LAYOUT.mastHeightMeters),
|
||||
},
|
||||
head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees },
|
||||
lenses: ["red", "yellow", "green"].map((state, index) => ({
|
||||
state,
|
||||
...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]),
|
||||
})),
|
||||
countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees },
|
||||
};
|
||||
}
|
||||
|
||||
function readTrafficSignals(stopLinePath, intersectionPath) {
|
||||
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")));
|
||||
}
|
||||
|
||||
function polygonCenter(geometry) {
|
||||
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
|
||||
if (!ring || ring.length < 4) return null;
|
||||
const points = ring.slice(0, -1);
|
||||
return [points.reduce((sum, point) => sum + point[0], 0) / points.length, points.reduce((sum, point) => sum + point[1], 0) / points.length];
|
||||
}
|
||||
|
||||
function polygonRadius(geometry, center) {
|
||||
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
|
||||
if (!ring || !center) return 0;
|
||||
return Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0);
|
||||
}
|
||||
|
||||
function roadAxis(geometry, center, target) {
|
||||
const ring = geometry?.coordinates?.[0];
|
||||
if (!ring || ring.length < 3) return null;
|
||||
let longest = null;
|
||||
for (let i = 0; i < ring.length - 1; i += 1) {
|
||||
const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180);
|
||||
const dy = ring[i + 1][1] - ring[i][1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!longest || length > longest.length) longest = { dx, dy, length };
|
||||
}
|
||||
if (!longest?.length) return null;
|
||||
let axis = [-longest.dy / longest.length, longest.dx / longest.length];
|
||||
const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]];
|
||||
if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]];
|
||||
return axis;
|
||||
}
|
||||
|
||||
function nearestCenter(point, centers) {
|
||||
return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null;
|
||||
}
|
||||
|
||||
function metersBetween(a, b) {
|
||||
const lat = (a[1] + b[1]) / 2 * Math.PI / 180;
|
||||
return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS;
|
||||
}
|
||||
|
||||
function moveMeters(point, vector, meters) {
|
||||
const scale = 180 / Math.PI / EARTH_RADIUS;
|
||||
return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale];
|
||||
}
|
||||
|
||||
module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals };
|
||||
@@ -36,6 +36,10 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-"));
|
||||
const input = path.join(tempDir, "input.osm");
|
||||
fs.writeFileSync(input, "<osm/>");
|
||||
const base = { id: "test-area", input, outputRoot: tempDir };
|
||||
assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSignals,
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signals.json"),
|
||||
);
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
|
||||
|
||||
@@ -9,6 +9,7 @@ const { cesiumPreviewHtml } = require("./lib/area-preview");
|
||||
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
||||
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
|
||||
const { buildTrafficSignals } = require("./lib/traffic-signals");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||
const osmPath = path.join(tempDir, "fixture.osm");
|
||||
@@ -113,15 +114,61 @@ const html = cesiumPreviewHtml(
|
||||
"vehicle.gltf",
|
||||
"north<&>\u2028valley",
|
||||
["car-a.gltf", "truck-a.gltf"],
|
||||
"traffic-signals.json",
|
||||
);
|
||||
assert.match(html, /<title>north<&>\u2028valley Cesium Preview<\/title>/);
|
||||
assert.match(html, /Loading scene<&>\.glb/);
|
||||
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
|
||||
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
|
||||
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
|
||||
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
|
||||
assert.match(html, /id="toggleSignals"/);
|
||||
assert.match(html, /id="viewMode"/);
|
||||
assert.match(html, /data-view-mode="inspect"/);
|
||||
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
|
||||
|
||||
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
|
||||
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
|
||||
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
|
||||
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
|
||||
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
|
||||
assert.match(previewRuntime, /asset\.category === "dynamic"/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
||||
assert.match(previewRuntime, /countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
||||
assert.match(previewRuntime, /asset\.category === "countdown"/);
|
||||
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
|
||||
assert.doesNotMatch(previewRuntime, /digitMap/);
|
||||
assert.match(previewRuntime, /Do not cache a miss/);
|
||||
assert.match(previewRuntime, /scene\.requestRender/);
|
||||
assert.doesNotMatch(previewRuntime, /function addTrafficSignals\(viewer, signalData, start, placement\)/);
|
||||
assert.doesNotMatch(previewRuntime, /ellipsoid:/);
|
||||
|
||||
const signals = buildTrafficSignals(
|
||||
{ type: "FeatureCollection", features: [
|
||||
rectangle(120.0000, 30.0000, 0.00003, 0.000006),
|
||||
rectangle(120.0002, 30.0000, 0.00003, 0.000006),
|
||||
] },
|
||||
{ type: "FeatureCollection", features: [
|
||||
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
|
||||
]] } },
|
||||
] },
|
||||
);
|
||||
assert.equal(signals.version, 3);
|
||||
assert.equal(signals.signals.length, 2);
|
||||
assert.deepEqual(signals.signals.map((signal) => signal.phaseGroup), [0, 1]);
|
||||
assert.ok(signals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
|
||||
assert.equal(signals.layout.countdownLateralMeters, 1.15);
|
||||
assert.equal(signals.layout.countdownWidthMeters, 0.82);
|
||||
assert.ok(signals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log("Preview asset tests passed.");
|
||||
|
||||
function rectangle(lon, lat, halfWidth, halfHeight) {
|
||||
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||
[lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - halfHeight],
|
||||
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
|
||||
]] } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user