feat: add cesium traffic signal countdowns

This commit is contained in:
2026-08-06 16:43:08 +08:00
parent 9fbc218e10
commit 043766b84e
22 changed files with 1022 additions and 170 deletions

View File

@@ -224,6 +224,29 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
`EXPORT_BASE_COLOR_OVERRIDES``EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表, `EXPORT_BASE_COLOR_OVERRIDES``EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表,
但它们只是旧 `.blend` 兼容回退。新材质不要只写旧表。 但它们只是旧 `.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` 记录了这个反复出现的问题: `export_cesium.py:38-54` 记录了这个反复出现的问题:
@@ -258,6 +281,8 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 | | 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain | | 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 | | 在 `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` **推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`
放进 `geom.py` 就立刻获得测试覆盖的资格。 放进 `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 停止线不得复制到输出。 // 原生 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 ### 1. Scope / Trigger

View File

@@ -201,6 +201,20 @@ GLB 停留在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSO
`scenePlacement(metadata)``:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。** `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. 范围与触发条件 ### 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 (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
if (!isOneWay(oneway)) edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward")); 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

@@ -1,10 +1,14 @@
# 设计Cesium 路口信号灯可视化 # 设计Cesium 路口信号灯可视化
## 边界 ## 分层边界
信号灯属于 Cesium 验证预览层,不进入 QGIS、GeoPackage、场景九图层、Blender 或主 GLB。 信号锚点不是 QGIS 业务图层:它不进入 GeoPackage、场景九图层或 QGIS 工程。`intermediates`
区域构建只额外写入一个轻量的信号锚点 JSON预览运行时读该 JSON 后以 Cesium 原生 `osm2streets_web_out/traffic_signals.json` 写出它Blender 和 preview 都消费同一份文件。
Entity/Primitive 构成灯杆、灯头和发光灯珠。
Blender 的 `05_Props` 负责所有静态设施:灯杆、横杆、灯头、熄灭灯珠和倒计时牌外壳。它们
`.blend` 和主 GLB 导出成为正式场景的一部分。Cesium 预览只负责动态覆盖层:当前相位的
发光灯珠、七段倒计时数字、Signals 显示开关,以及车辆在红黄灯前的等待。这样静态造型只有
一份,浏览器不再用临时 Entity 重复搭建设施。
## 锚点与几何 ## 锚点与几何
@@ -13,7 +17,14 @@ Entity/Primitive 构成灯杆、灯头和发光灯珠。
侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时, 侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时,
不输出锚点。 不输出锚点。
输出保存灯杆坐标、对应停止线坐标、朝向稳定 ID,避免浏览器重新解析 GeoJSON 或 OSM。 输出保存灯杆坐标、对应停止线坐标、朝向稳定 ID`layout` 几何契约,避免 Blender 或
浏览器重新解析 GeoJSON 或 OSM。`layout` 包含灯头、灯珠、横杆和倒计时牌的尺寸与偏移;
其中横向偏移以车辆行驶方向为基准,正值表示驾驶员右侧;`mastHeightMeters`
`headCenterHeightMeters` 是横杆和灯壳的共同中心高度。三颗灯珠相对灯壳中心排列,而倒计时牌
的垂直偏移为零、固定在横杆上。
Blender 使用
`Projector.xy((longitude, latitude))` 转成本地米制坐标,并以 `headingDegrees` 旋转Cesium
以同一字段派生地理位置与灯面朝向。
每个路口按相对进口方向分为两组对向相位;统一循环绿、黄、全红切换。 每个路口按相对进口方向分为两组对向相位;统一循环绿、黄、全红切换。
预览将每条路线按累计米数投影到信号停止线。只有距离阈值内且行驶方向与信号进口一致的 预览将每条路线按累计米数投影到信号停止线。只有距离阈值内且行驶方向与信号进口一致的
@@ -23,10 +34,11 @@ Entity/Primitive 构成灯杆、灯头和发光灯珠。
## 预览交互 ## 预览交互
预览加载锚点 JSON 失败时记录 warning场景、路线与车辆仍可用。加载成功时信号灯默认显示 预览加载锚点 JSON 失败时记录 warning场景、路线与车辆仍可用。加载成功时信号灯默认显示
并在现有 View 控件中提供独立 Signals 复选框。灯珠以 emissive 材质区分点亮和熄灭状态, 并在现有 View 控件中提供独立 Signals 复选框。动态灯珠和数字以 emissive 材质区分点亮和
不依赖环境光;灯杆用低多边形几何,避免增加外部模型资产 熄灭状态,不依赖环境光;静态部分由 GLB 的低多边形 MeshBatch 几何承载
## 风险与回退 ## 风险与回退
信号灯是示意设施,不能视为 OSM 语义。复杂交叉口或人工修补后的不完整标线宁可跳过,也不 信号灯是示意设施,不能视为 OSM 语义。复杂交叉口或人工修补后的不完整标线宁可跳过,也不
摆放到行车道中央。删除新锚点输出及预览加载逻辑即可完整回退,不影响既有主 GLB 或路线 JSON。 摆放到行车道中央。回退时删除附属锚点输出、`05_Props` 信号构件和 Cesium 动态覆盖层即可;
QGIS、道路/标线和既有路线 JSON 不受影响。

View File

@@ -1,21 +1,42 @@
# 实施计划Cesium 路口信号灯可视化 # 实施计划Cesium 路口信号灯可视化
1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。 1. 复用 `traffic-signals.js` 的锚点推导,在 `intermediates` 阶段把附属
2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest不触及主 GLB `traffic_signals.json` 写入 `geojsonDir`;不修改 `SCENE_LAYERS`、GeoPackage 或 QGIS
3.Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位钟更新状态。 2.`blender/osmassets/traffic_signals.py` 用共享低多边形 MeshBatch 几何装配静态信号设施,
4. 将路线投影到匹配停止线,在红/黄相位冻结累计里程、绿灯恢复推进;未匹配路线保持原行为 并在 `generate_scene.py` 读取锚点、投影坐标、置入 `05_Props` 和写入计数
5. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局。 3. `catalog.MATERIALS` 末尾追加信号设施材质和 Cesium 导出补偿,保证新 GLB 在 Cesium
6. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、相位与停车 中不会发黑或材质索引漂移
4. 让 preview 直接读取 intermediates 的锚点文件;删除 Cesium 对立杆、横杆、灯头、熄灭灯珠
和外壳的构造,只保留精确对齐的动态灯珠、数字与既有相位/车辆等待。
5. 更新 Node 与纯 Python 测试,构建目标区域并用 parity 检查 GLB 差异只包含预期新增设施;
人工核对主 GLB 静态构件和 preview 动态覆盖层。
## 验证 ## 验证
```bash ```bash
npm run test:preview-assets npm run test:preview-assets
node --check scripts/lib/cesium-preview.js node --check scripts/lib/cesium-preview.js
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview 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 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
均成功生成,它不是阻断错误。
## 回退 ## 回退
删除信号锚点的 preview 输出浏览器加载代码QGIS、GLB、车辆路线和原有预览功能不受影响。 删除静态信号 Blender 模块、附属锚点输出浏览器动态覆盖层QGIS、道路/标线和既有路线
JSON 不受影响。

View File

@@ -13,7 +13,10 @@
- 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。 - 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。
- 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补 - 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补
边界、道路生成与既有连续路线逻辑。 边界、道路生成与既有连续路线逻辑。
- Cesium 预览是验证层;改动它不应改变主 GLB、Blender 导出或 OSM/QGIS 产物。 - Cesium 当前同时绘制灯杆、横杆、灯头、熄灭灯珠、倒计时外壳,以及随相位变化的灯珠和
七段数字。这使静态设施只存在于验证层,难以随主场景维护。
- `05_Props` 集合已经进入主 `.blend` 和 Cesium GLB`traffic-signals.js` 已是停止线和
路口面推导信号锚点的唯一事实源。
## Requirements ## Requirements
@@ -26,16 +29,24 @@
- 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。 - 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。
- 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠 - 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠
几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。 几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。
- 灯杆、横杆、灯壳、熄灭灯珠和倒计时牌外壳必须成为 `05_Props` 中的静态场景几何,随
主 GLB 导出Cesium 只保留与这些几何严格对齐的发光灯珠、七段倒计时数字、显示开关和
车辆相位等待。
- 信号锚点必须在 `intermediates` 阶段写入 `osm2streets_web_out/traffic_signals.json`,由
Blender 与 preview 共用;不得纳入 `SCENE_LAYERS`、GeoPackage 或 QGIS 工程。
## Acceptance Criteria ## Acceptance Criteria
- [ ] 重建目标区域的 preview 后Cesium 中可见至少一组位于路口进口侧的信号灯,位置与停止线/ - [ ] 仅运行 `intermediates,blender,cesium` 后,主 GLB 已包含位于路口进口侧的灯杆、横杆、
灯头、熄灭灯珠和倒计时牌外壳;静态几何位置与停止线/
斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。 斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。
- [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。 - [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。
- [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。 - [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。
- [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的 - [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的
路线保持原有循环巡航,不因信号锚点缺失而卡住。 路线保持原有循环巡航,不因信号锚点缺失而卡住。
- [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。 - [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。
- [ ] preview 重建后,动态灯珠和数字与 GLB 中相应灯头、倒计时外壳对齐,且无 Cesium
重复的杆、横杆、灯壳或外壳实体。
## Notes ## Notes

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(): 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 [] argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0 i = 0
while i < len(argv): while i < len(argv):
if argv[i].startswith("--") and i + 1 < 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 i += 2
else: else:
i += 1 i += 1
@@ -609,6 +609,8 @@ def export(args):
material_map = {} material_map = {}
meshes = [] meshes = []
dynamic_meshes = []
countdown_meshes = {0: [], 1: []}
unwrapped = set() unwrapped = set()
for obj in bpy.context.scene.objects: for obj in bpy.context.scene.objects:
if obj.type != "MESH": if obj.type != "MESH":
@@ -617,6 +619,13 @@ def export(args):
continue continue
if obj.hide_viewport or obj.hide_render: if obj.hide_viewport or obj.hide_render:
continue continue
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) meshes.append(obj)
apply_mesh_modifiers(obj) apply_mesh_modifiers(obj)
# Hundreds of grass tufts share four mesh datablocks; unwrapping and # Hundreds of grass tufts share four mesh datablocks; unwrapping and
@@ -642,6 +651,14 @@ def export(args):
slot.material = material_map[source.name] slot.material = material_map[source.name]
export_glb(args["glb"], meshes) 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) semantic_assets = semantic_asset_specs(args["glb"], meshes)
for asset in semantic_assets: for asset in semantic_assets:
export_glb(asset["path"], asset["meshes"]) export_glb(asset["path"], asset["meshes"])
@@ -664,6 +681,19 @@ def export(args):
"type": "model", "type": "model",
"url": os.path.basename(args["glb"]), "url": os.path.basename(args["glb"]),
"enabled": True, "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"], "id": asset["id"],
"label": asset["label"], "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 roads as _roads # noqa: E402
from osmassets import scrub as _scrub # noqa: E402 from osmassets import scrub as _scrub # noqa: E402
from osmassets import tree as _tree # 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( CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
@@ -638,6 +639,7 @@ def build(args):
roads_c = new_collection("03_Roads") roads_c = new_collection("03_Roads")
buildings_c = new_collection("04_Buildings") buildings_c = new_collection("04_Buildings")
props_c = new_collection("05_Props") props_c = new_collection("05_Props")
traffic_dynamic_c = new_collection("06_TrafficSignalsDynamic")
ground_mat = material_from_spec(catalog.MATERIALS["ground"]) ground_mat = material_from_spec(catalog.MATERIALS["ground"])
water_mat = material_from_spec(catalog.MATERIALS["water"]) water_mat = material_from_spec(catalog.MATERIALS["water"])
@@ -661,6 +663,25 @@ def build(args):
layer["id"]: material_from_spec(spec) layer["id"]: material_from_spec(spec)
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs()) 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 b = bounds
scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"])) scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"]))
@@ -690,6 +711,7 @@ def build(args):
"scrub_bush_count": 0, "scrub_bush_count": 0,
"scrub_count": 0, "scrub_count": 0,
"scrub_tree_count": 0, "scrub_tree_count": 0,
"traffic_signal_count": 0,
} }
def add_scrub_patch_with_bushes(name, ring, ground_material, collection): def add_scrub_patch_with_bushes(name, ring, ground_material, collection):
@@ -781,6 +803,22 @@ def build(args):
_roads.assemble_osm_fallback( _roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"]) 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 = [] trees = []
individual_tree_count = 0 individual_tree_count = 0
for feature in point_features: for feature in point_features:
@@ -883,6 +921,7 @@ def build(args):
scene["scrub_bush_count"] = counts["scrub_bush_count"] scene["scrub_bush_count"] = counts["scrub_bush_count"]
scene["scrub_tree_count"] = counts["scrub_tree_count"] scene["scrub_tree_count"] = counts["scrub_tree_count"]
scene["fountain_count"] = counts["fountain_count"] scene["fountain_count"] = counts["fountain_count"]
scene["traffic_signal_count"] = counts["traffic_signal_count"]
scene["tree_node_count"] = individual_tree_count scene["tree_node_count"] = individual_tree_count
scene["tree_row_count"] = row_tree_count scene["tree_row_count"] = row_tree_count
scene["tree_count"] = len(trees) scene["tree_count"] = len(trees)
@@ -914,6 +953,8 @@ def build(args):
"scrub_bushes": counts["scrub_bush_count"], "scrub_bushes": counts["scrub_bush_count"],
"scrub_trees": counts["scrub_tree_count"], "scrub_trees": counts["scrub_tree_count"],
"fountains": counts["fountain_count"], "fountains": counts["fountain_count"],
"traffic_signals": counts["traffic_signal_count"],
"traffic_signal_dynamic_objects": dynamic_signal_objects,
"tree_nodes": individual_tree_count, "tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count, "tree_row_instances": row_tree_count,
"trees": len(trees), "trees": len(trees),

View File

@@ -152,6 +152,41 @@ MATERIALS = {
"cesium": {"tint": None, "cesium": {"tint": None,
"base_color": (0.11, 0.34, 0.075), "base_color": (0.11, 0.34, 0.075),
"emission": ((0.04, 0.11, 0.035), 0.02)}}, "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

@@ -182,6 +182,7 @@ function buildIntermediates(area) {
"--config", "--config",
derivedConfigPath, derivedConfigPath,
], "intermediates"); ], "intermediates");
writeTrafficSignals(area);
fs.rmSync(stageManifestPath(area, "reimport"), { force: true }); fs.rmSync(stageManifestPath(area, "reimport"), { force: true });
const finished = Date.now(); const finished = Date.now();
writeStageManifest(area, { writeStageManifest(area, {
@@ -199,6 +200,7 @@ function buildIntermediates(area) {
derivedConfig: fileRecord(derivedConfigPath), derivedConfig: fileRecord(derivedConfigPath),
geojsonDir: fileRecord(area.outputs.geojsonDir), geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area), ...sceneGeojsonRecords(area),
trafficSignals: fileRecord(area.outputs.trafficSignals),
gpkg: fileRecord(area.outputs.gpkg), gpkg: fileRecord(area.outputs.gpkg),
qgisProject: fileRecord(area.outputs.qgisProject), qgisProject: fileRecord(area.outputs.qgisProject),
qgisPreview: optionalFileRecord(area.outputs.qgisPreview), qgisPreview: optionalFileRecord(area.outputs.qgisPreview),
@@ -221,6 +223,7 @@ function reimportGpkg(area) {
"--config", "--config",
derivedConfigPath, derivedConfigPath,
], "reimport"); ], "reimport");
writeTrafficSignals(area);
fs.rmSync(stageManifestPath(area, "intermediates"), { force: true }); fs.rmSync(stageManifestPath(area, "intermediates"), { force: true });
const finished = Date.now(); const finished = Date.now();
writeStageManifest(area, { writeStageManifest(area, {
@@ -238,6 +241,7 @@ function reimportGpkg(area) {
outputs: { outputs: {
geojsonDir: fileRecord(area.outputs.geojsonDir), geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area), ...sceneGeojsonRecords(area),
trafficSignals: fileRecord(area.outputs.trafficSignals),
}, },
summary: { summary: {
geojson: geojsonFeatureCounts(area), geojson: geojsonFeatureCounts(area),
@@ -249,6 +253,7 @@ function reimportGpkg(area) {
function buildBlenderScene(area) { function buildBlenderScene(area) {
ensureFile(blenderExecutable(area), "Blender executable"); ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator"); 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.blend), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true }); fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
@@ -290,6 +295,7 @@ function buildBlenderScene(area) {
osm: fileRecord(area.input), osm: fileRecord(area.input),
geojsonDir: fileRecord(area.outputs.geojsonDir), geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area), ...sceneGeojsonRecords(area),
trafficSignals: fileRecord(area.outputs.trafficSignals),
}, },
outputs: { outputs: {
blend: fileRecord(area.outputs.blend), blend: fileRecord(area.outputs.blend),
@@ -324,9 +330,18 @@ function exportCesium(area) {
area.outputs.blend, area.outputs.blend,
"--glb", "--glb",
area.outputs.glb, area.outputs.glb,
"--dynamic-glb",
area.outputs.trafficSignalsDynamicGlb,
"--countdown-0-glb",
area.outputs.trafficSignalsCountdown0Glb,
"--countdown-1-glb",
area.outputs.trafficSignalsCountdown1Glb,
"--metadata", "--metadata",
area.outputs.metadata, area.outputs.metadata,
], "cesium"); ], "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); const semanticAssets = semanticAssetRecords(area);
writeCesiumPreview(area); writeCesiumPreview(area);
const finished = Date.now(); const finished = Date.now();
@@ -344,6 +359,7 @@ function exportCesium(area) {
outputs: { outputs: {
glb: fileRecord(area.outputs.glb), glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata), metadata: fileRecord(area.outputs.metadata),
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
semanticAssets, semanticAssets,
}, },
summary: { summary: {
@@ -466,19 +482,19 @@ function runCommand(command, commandArgs, stage) {
function writeCesiumPreview(area) { function writeCesiumPreview(area) {
ensureFile(area.outputs.glb, "Cesium GLB"); ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata"); ensureFile(area.outputs.metadata, "Cesium metadata");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
const htmlPath = area.outputs.cesiumPreview; const htmlPath = area.outputs.cesiumPreview;
const started = Date.now(); const started = Date.now();
const startedAt = new Date(started).toISOString(); const startedAt = new Date(started).toISOString();
fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
writeVehicleRoute(area); writeVehicleRoute(area);
writeTrafficSignals(area);
const vehicleModelNames = writeVehicleModel(area); const vehicleModelNames = writeVehicleModel(area);
writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = path.basename(area.outputs.glb); const glbName = path.basename(area.outputs.glb);
const metadataName = path.basename(area.outputs.metadata); const metadataName = path.basename(area.outputs.metadata);
const routeName = path.basename(area.outputs.vehicleRoute); const routeName = path.basename(area.outputs.vehicleRoute);
const vehicleModelName = path.basename(area.outputs.vehicleModel); const vehicleModelName = path.basename(area.outputs.vehicleModel);
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, path.basename(area.outputs.trafficSignals))); fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals)));
console.log(`Cesium preview: ${htmlPath}`); console.log(`Cesium preview: ${htmlPath}`);
const finished = Date.now(); const finished = Date.now();
writeStageManifest(area, { writeStageManifest(area, {
@@ -516,6 +532,10 @@ function writeTrafficSignals(area) {
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`); 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) { function writeVehicleRoute(area) {
const route = buildPreviewVehicleRoute(area.input); const route = buildPreviewVehicleRoute(area.input);
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true }); fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });

View File

@@ -28,15 +28,21 @@ function normalizeAreaConfig(raw, options = {}) {
const compressedFileStem = outputOverrides.compressedFileStem || const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`; `${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")); const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const outputs = { const outputs = {
areaDir, areaDir,
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")), geojsonDir,
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)), gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)), qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)), qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)), blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)), render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)), 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`)), metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
cesiumPreview: path.resolve( cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`), outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
@@ -52,7 +58,9 @@ function normalizeAreaConfig(raw, options = {}) {
), ),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)), vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)), vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(areaDir, `${fileStem}-traffic-signals.json`)), // 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, pipelineDir,
stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")), stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")),
}; };

View File

@@ -108,7 +108,7 @@ function previewSummary(area) {
metadataName: path.basename(area.outputs.metadata), metadataName: path.basename(area.outputs.metadata),
routeName: path.basename(area.outputs.vehicleRoute), routeName: path.basename(area.outputs.vehicleRoute),
vehicleModelName: path.basename(area.outputs.vehicleModel), vehicleModelName: path.basename(area.outputs.vehicleModel),
trafficSignalsName: path.basename(area.outputs.trafficSignals), trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
routeSegments: Array.isArray(route.segments) ? route.segments.length : null, routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
}; };
} }

View File

@@ -41,7 +41,7 @@
setLoadingMessage("Loading model", config.glbName || ""); setLoadingMessage("Loading model", config.glbName || "");
const assets = await loadSceneAssets(viewer, metadata, placement); const assets = await loadSceneAssets(viewer, metadata, placement);
const trafficStart = Cesium.JulianDate.now(); const trafficStart = Cesium.JulianDate.now();
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart); const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName); const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
const cameras = createCameraPresets(viewer, metadata, placement, cruise); const cameras = createCameraPresets(viewer, metadata, placement, cruise);
@@ -297,7 +297,7 @@
toggleVehicles.addEventListener("change", () => { toggleVehicles.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked; for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
}); });
if (!trafficSignals.entities.length) { if (!trafficSignals.count) {
signalsControl.classList.add("hidden"); signalsControl.classList.add("hidden");
} else { } else {
toggleSignals.addEventListener("change", () => { toggleSignals.addEventListener("change", () => {
@@ -464,145 +464,91 @@
}; };
} }
function addTrafficSignals(viewer, signalData, start) { function addTrafficSignals(viewer, signalData, start, assets) {
const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude)); 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 entities = [];
for (const signal of anchors) { const nodes = new Map();
const mastReach = Number(signal.mastReachMeters) || 4.5; const node = (name) => {
const countdownOffset = -1.15; if (nodes.has(name)) return nodes.get(name);
const polePosition = signalPosition(signal, 0, 0, 3.35); let value = null;
const headPosition = signalPosition(signal, 0, -mastReach, 6.25); try {
const frame = signalHeadFrame(signal, headPosition); value = dynamic.model.getNode(name);
const pole = viewer.entities.add({ } catch (error) {
position: polePosition, console.warn("Traffic signal node unavailable:", name, error);
cylinder: { length: 6.7, topRadius: 0.10, bottomRadius: 0.14, material: Cesium.Color.fromCssColorString("#273139") },
});
// The mast arm begins at the curbside pole and reaches above the approach
// lanes. A separate mast at the opposite approach controls oncoming cars.
const arm = viewer.entities.add({
polyline: {
positions: [signalPosition(signal, 0, 0, 6.25), headPosition],
width: 9,
material: Cesium.Color.fromCssColorString("#273139"),
arcType: Cesium.ArcType.NONE,
},
});
const head = viewer.entities.add({
position: headPosition,
orientation: frame.orientation,
box: { dimensions: new Cesium.Cartesian3(0.68, 0.30, 1.62), material: Cesium.Color.fromCssColorString("#182024") },
});
entities.push(pole, arm, head);
for (const [index, state] of ["red", "yellow", "green"].entries()) {
const bulb = viewer.entities.add({
// The lens sits on the explicit approach-facing normal of the head,
// not at an angle inferred from the box's local axes.
position: signalLensPosition(headPosition, frame, 0.18, 0.49 - index * 0.50),
ellipsoid: {
radii: new Cesium.Cartesian3(0.22, 0.22, 0.22),
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalColor(signal.phaseGroup, state, time, start), false)),
},
});
entities.push(bulb);
} }
// The countdown board mounts on the mast between the head and pole, // Do not cache a miss. Cesium can expose the Model before its node
// rather than protruding beyond the signal on the roadway side. // lookup table is populated; a transient miss must be retried on the
const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0); // next clock tick rather than freezing the initial visual state.
const counter = viewer.entities.add({ if (value) nodes.set(name, value);
position: counterPosition, return value;
orientation: frame.orientation, };
box: { const update = (elapsedSeconds) => {
dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56), Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
material: Cesium.Color.fromCssColorString("#251f1c"), let changed = false;
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), const groupPhases = new Map();
}, for (const signal of signals) {
}); const phase = signalPhase(signal.phaseGroup, phaseTime, start);
const countdown = createSevenSegmentCountdown( groupPhases.set(signal.phaseGroup, phase.active);
viewer, if (signal === signals[0]) state.phase = `${phase.active} ${String(phase.remaining).padStart(2, "0")}`;
signal, for (const state of ["red", "yellow", "green"]) {
start, const value = node(`TrafficSignalDynamic_${signal.id}_${state}`);
headPosition, if (value && value.show !== (state === phase.active)) {
frame, value.show = state === phase.active;
countdownOffset, changed = true;
);
entities.push(counter, ...countdown);
} }
}
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 { return {
entities, entities, count: signals.length, dynamic, state, timer,
count: anchors.length, set show(value) {
set show(value) { for (const entity of entities) entity.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 signalPosition(signal, longitudinalMeters, lateralMeters, height) {
const heading = Cesium.Math.toRadians(signal.headingDegrees);
const latitude = signal.latitude + (longitudinalMeters * Math.cos(heading) - lateralMeters * Math.sin(heading)) / 110540;
const longitude = signal.longitude + (longitudinalMeters * Math.sin(heading) + lateralMeters * Math.cos(heading)) /
(111320 * Math.cos(Cesium.Math.toRadians(signal.latitude)));
return Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
}
function signalHeadFrame(signal, position) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
// headingDegrees is the approach's travel direction into the junction.
// The signal's front must point back toward that approaching traffic.
const heading = Cesium.Math.toRadians(signal.headingDegrees);
const localFace = new Cesium.Cartesian3(-Math.sin(heading), -Math.cos(heading), 0);
const face = Cesium.Matrix4.multiplyByPointAsVector(enu, localFace, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(face, face);
const up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3());
const across = Cesium.Cartesian3.cross(face, up, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(across, across);
const rotation = new Cesium.Matrix3(
across.x, face.x, up.x,
across.y, face.y, up.y,
across.z, face.z, up.z,
);
return { across, face, up, orientation: Cesium.Quaternion.fromRotationMatrix(rotation, new Cesium.Quaternion()) };
}
function signalLensPosition(headPosition, frame, faceOffset, verticalOffset) {
const point = Cesium.Cartesian3.multiplyByScalar(frame.face, faceOffset, new Cesium.Cartesian3());
Cesium.Cartesian3.add(headPosition, point, point);
const vertical = Cesium.Cartesian3.multiplyByScalar(frame.up, verticalOffset, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(point, vertical, point);
}
function signalPanelPosition(headPosition, frame, acrossOffset, faceOffset, verticalOffset) {
const point = signalLensPosition(headPosition, frame, faceOffset, verticalOffset);
const across = Cesium.Cartesian3.multiplyByScalar(frame.across, acrossOffset, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(point, across, point);
}
function createSevenSegmentCountdown(viewer, signal, start, headPosition, frame, boardAcross) {
const digitMap = { "0": "abcedf", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd" };
const shape = {
a: [0, 0.18, 0.20, 0.025, 0.035], b: [0.10, 0.085, 0.035, 0.025, 0.15],
c: [0.10, -0.085, 0.035, 0.025, 0.15], d: [0, -0.18, 0.20, 0.025, 0.035],
e: [-0.10, -0.085, 0.035, 0.025, 0.15], f: [-0.10, 0.085, 0.035, 0.025, 0.15],
g: [0, 0, 0.20, 0.025, 0.035],
};
const result = [];
for (const [digitIndex, digitAcross] of [-0.17, 0.17].entries()) {
for (const [name, [x, z, width, depth, height]] of Object.entries(shape)) {
result.push(viewer.entities.add({
// The visible panel x-axis is the inverse of the signal frame's
// across axis. Mirror the LED layout once here to keep digits normal.
position: signalPanelPosition(headPosition, frame, boardAcross - digitAcross - x, 0.18, z),
orientation: frame.orientation,
box: {
dimensions: new Cesium.Cartesian3(width, depth, height),
material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalActiveColor(signal.phaseGroup, time, start), false)),
show: new Cesium.CallbackProperty((time) => {
const value = String(signalPhase(signal.phaseGroup, time, start).remaining).padStart(2, "0")[digitIndex];
return (digitMap[value] || "").includes(name);
}, false),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220),
},
}));
}
}
return result;
} }
function signalColor(group, state, time, start) { function signalColor(group, state, time, start) {
@@ -620,7 +566,11 @@
} }
function signalPhase(group, time, start) { function signalPhase(group, time, start) {
const second = ((Cesium.JulianDate.secondsDifference(time, start) % 20) + 20) % 20; return signalPhaseAtElapsed(group, Cesium.JulianDate.secondsDifference(time, start));
}
function signalPhaseAtElapsed(group, elapsed) {
const second = ((elapsed % 20) + 20) % 20;
if (group === 0) { if (group === 0) {
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) }; if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) }; if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };

View File

@@ -5,6 +5,32 @@ const fs = require("fs");
const EARTH_RADIUS = 6371008.8; const EARTH_RADIUS = 6371008.8;
const CURB_OFFSET_METERS = 5.2; const CURB_OFFSET_METERS = 5.2;
const MAST_REACH_METERS = 4.5; 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) { function buildTrafficSignals(stopLines, intersections) {
const centers = (intersections.features || []).map((feature, index) => { const centers = (intersections.features || []).map((feature, index) => {
@@ -37,9 +63,36 @@ function buildTrafficSignals(stopLines, intersections) {
stopLatitude: center[1], stopLatitude: center[1],
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI, headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
mastReachMeters: MAST_REACH_METERS, mastReachMeters: MAST_REACH_METERS,
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
}); });
} }
return { version: 1, signals }; 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) { function readTrafficSignals(stopLinePath, intersectionPath) {
@@ -90,4 +143,4 @@ function moveMeters(point, vector, meters) {
return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale];
} }
module.exports = { buildTrafficSignals, readTrafficSignals }; 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"); const input = path.join(tempDir, "input.osm");
fs.writeFileSync(input, "<osm/>"); fs.writeFileSync(input, "<osm/>");
const base = { id: "test-area", input, outputRoot: tempDir }; 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.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
assert.throws( assert.throws(
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }), () => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),

View File

@@ -127,6 +127,23 @@ assert.match(html, /id="viewMode"/);
assert.match(html, /data-view-mode="inspect"/); assert.match(html, /data-view-mode="inspect"/);
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/); 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( const signals = buildTrafficSignals(
{ type: "FeatureCollection", features: [ { type: "FeatureCollection", features: [
rectangle(120.0000, 30.0000, 0.00003, 0.000006), rectangle(120.0000, 30.0000, 0.00003, 0.000006),
@@ -138,10 +155,13 @@ const signals = buildTrafficSignals(
]] } }, ]] } },
] }, ] },
); );
assert.equal(signals.version, 1); assert.equal(signals.version, 3);
assert.equal(signals.signals.length, 2); assert.equal(signals.signals.length, 2);
assert.deepEqual(signals.signals.map((signal) => signal.phaseGroup), [0, 1]); assert.deepEqual(signals.signals.map((signal) => signal.phaseGroup), [0, 1]);
assert.ok(signals.signals.every((signal) => Number.isFinite(signal.headingDegrees))); 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 }); fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Preview asset tests passed."); console.log("Preview asset tests passed.");