6 Commits

27 changed files with 1435 additions and 28 deletions

View File

@@ -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 个数字 mesh0-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 共享 |
## 第三方资产导入的源文件边界

View File

@@ -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`,不能依赖其他测试的导入顺序。
---
## 反模式

View File

@@ -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

View File

@@ -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. 范围与触发条件

View File

@@ -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 坐标轴。

View File

@@ -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."}

View File

@@ -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 不受影响。

View File

@@ -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."}

View File

@@ -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 不受影响。

View File

@@ -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`.

View File

@@ -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": {}
}

View File

@@ -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` |

View File

@@ -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

Binary file not shown.

8
assets/fonts/SOURCES.md Normal file
View 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.

View File

@@ -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"],

View File

@@ -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),

View File

@@ -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)}},
}

View 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))

View 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()

View File

@@ -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 });

View File

@@ -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")),
};

View File

@@ -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,
};
}

View File

@@ -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")

View 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 };

View File

@@ -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 } }),

View File

@@ -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&lt;&amp;&gt;\u2028valley Cesium Preview<\/title>/);
assert.match(html, /Loading scene&lt;&amp;&gt;\.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],
]] } };
}