diff --git a/.trellis/spec/blender/asset-generation.md b/.trellis/spec/blender/asset-generation.md index 7c48990..1922ab9 100644 --- a/.trellis/spec/blender/asset-generation.md +++ b/.trellis/spec/blender/asset-generation.md @@ -224,6 +224,29 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295) `EXPORT_BASE_COLOR_OVERRIDES`、`EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表, 但它们只是旧 `.blend` 兼容回退。新材质不要只写旧表。 +### 交通信号倒计时字体 + +`assets/fonts/7LED-1.ttf` 是项目纳入版本管理的倒计时字体。它的字形是反向轮廓:可见 +的 LED 段是字体轮廓里的孔,而不是普通实心文字。因此 Blender 侧不能直接把文字曲线 +转成普通填充面(会得到“发光背景+黑色数字”),也不能依赖曲线描边。正确做法是在 +`blender/osmassets/traffic_signals.py` 中采样负 Bezier 轮廓,构造带前后盖面的挤出棱柱, +使 LED 段成为实心发光几何。数字 mesh 必须先在 Blender 中单独渲染确认,再进入 Cesium +导出;导出器出现“Could not calculate tangents”只表示这些无 UV 的纯色网格没有切线, +不等同于倒计时集合为空或几何失败。 + +### 共享与拆分动态资产 + +倒计时数字按 phase group 共享 20 个数字 mesh(0-19),不要按信号灯复制网格。Cesium +阶段必须生成三个动态 GLB:`traffic-signals-dynamic.glb` 只含灯珠, +`traffic-signals-countdown-0.glb` 和 `traffic-signals-countdown-1.glb` 分别含两个相位组的 +倒计时节点。两个倒计时模型与灯珠模型使用同一个 `modelMatrix`,浏览器只切换当前数字 +节点,并给整个倒计时模型设置 `color` + `ColorBlendMode.REPLACE`,从而让字色跟随当前 +红/黄/绿相位且不增加每个灯的材质/几何副本。 + +导出器按完整材质名包含 `Countdown Group 0` / `Countdown Group 1` 判断分组;不能用 +集合名的精确相等比较,否则实际材质名 `Traffic Signal Countdown Group 0` 会被误判为 +空集合。 + ### 为什么新资产总是"发黑" `export_cesium.py:38-54` 记录了这个反复出现的问题: @@ -258,6 +281,8 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295) | 加新资产不配 Cesium 调色 | Cesium 里显得发黑 | | 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain | | 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 | +| 直接用 Cesium `Model.getMaterial().setValue()` 改普通 glTF PBR 材质 | 运行时数字仍保持原色,不能实现相位字色 | +| 每个信号灯各自生成 0-19 全套倒计时 mesh | 节点和几何按信号数量线性膨胀;应按两个 phase group 共享 | ## 第三方资产导入的源文件边界 diff --git a/.trellis/spec/blender/testing.md b/.trellis/spec/blender/testing.md index a512b9d..609b476 100644 --- a/.trellis/spec/blender/testing.md +++ b/.trellis/spec/blender/testing.md @@ -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`,不能依赖其他测试的导入顺序。 + --- ## 反模式 diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 02929ea..06ba84e 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -343,6 +343,84 @@ out.vehicleStopLines = crosswalkData.stopLines; // 原生 lane_markings 停止线不得复制到输出。 ``` +## 信号锚点的跨阶段消费 + +### 1. Scope / Trigger + +路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用 +`/traffic_signals.json`。它是附属 intermediates 产物,而不是第十个 +osm2streets/QGIS 图层。 + +### 2. Signatures + +```bash +npm run build:area -- --config config/areas/.json --stages intermediates,blender,cesium,preview +``` + +`normalizeAreaConfig()` 将默认路径归一化为: + +```js +area.outputs.trafficSignals +// /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 diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index f1881e4..e97694b 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -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. 范围与触发条件 diff --git a/.trellis/spec/preview/vehicle-routes.md b/.trellis/spec/preview/vehicle-routes.md index ecb0ce9..10191ac 100644 --- a/.trellis/spec/preview/vehicle-routes.md +++ b/.trellis/spec/preview/vehicle-routes.md @@ -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__`;浏览器在同一 model +matrix 下加载该 GLB,并只切换这些灯珠节点。倒计时 Entity 使用 `pose.countdown` 的经纬度、 +高度、`faceHeadingDegrees` 生成与牌面相同的 ENU 坐标轴。 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/design.md b/.trellis/tasks/08-05-cesium-traffic-signals/design.md index 890ca05..a6d4f72 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/design.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/design.md @@ -1,10 +1,14 @@ # 设计:Cesium 路口信号灯可视化 -## 边界 +## 分层边界 -信号灯属于 Cesium 验证预览层,不进入 QGIS、GeoPackage、场景九图层、Blender 或主 GLB。 -区域构建只额外写入一个轻量的信号锚点 JSON;预览运行时读该 JSON 后以 Cesium 原生 -Entity/Primitive 构成灯杆、灯头和发光灯珠。 +信号锚点不是 QGIS 业务图层:它不进入 GeoPackage、场景九图层或 QGIS 工程。`intermediates` +在 `osm2streets_web_out/traffic_signals.json` 写出它;Blender 和 preview 都消费同一份文件。 + +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,场景、路线与车辆仍可用。加载成功时信号灯默认显示, -并在现有 View 控件中提供独立 Signals 复选框。灯珠以 emissive 材质区分点亮和熄灭状态, -不依赖环境光;灯杆用低多边形几何,避免增加外部模型资产。 +并在现有 View 控件中提供独立 Signals 复选框。动态灯珠和数字以 emissive 材质区分点亮和 +熄灭状态,不依赖环境光;静态部分由 GLB 的低多边形 MeshBatch 几何承载。 ## 风险与回退 信号灯是示意设施,不能视为 OSM 语义。复杂交叉口或人工修补后的不完整标线宁可跳过,也不 -摆放到行车道中央。删除新锚点输出及预览加载逻辑即可完整回退,不影响既有主 GLB 或路线 JSON。 +摆放到行车道中央。回退时删除附属锚点输出、`05_Props` 信号构件和 Cesium 动态覆盖层即可; +QGIS、道路/标线和既有路线 JSON 不受影响。 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/implement.md b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md index 676d0a0..3610162 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/implement.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md @@ -1,21 +1,42 @@ # 实施计划:Cesium 路口信号灯可视化 -1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。 -2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest,不触及主 GLB。 -3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位钟更新状态。 -4. 将路线投影到匹配停止线,在红/黄相位冻结累计里程、绿灯恢复推进;未匹配路线保持原行为。 -5. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局。 -6. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、相位与停车。 +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 -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 ``` +## 已验证决策与故障记录 + +- `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 不受影响。 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/prd.md b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md index ec6bda8..b378a85 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/prd.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md @@ -13,7 +13,10 @@ - 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。 - 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补 边界、道路生成与既有连续路线逻辑。 -- Cesium 预览是验证层;改动它不应改变主 GLB、Blender 导出或 OSM/QGIS 产物。 +- Cesium 当前同时绘制灯杆、横杆、灯头、熄灭灯珠、倒计时外壳,以及随相位变化的灯珠和 + 七段数字。这使静态设施只存在于验证层,难以随主场景维护。 +- `05_Props` 集合已经进入主 `.blend` 和 Cesium GLB;`traffic-signals.js` 已是停止线和 + 路口面推导信号锚点的唯一事实源。 ## Requirements @@ -26,16 +29,24 @@ - 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。 - 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠 几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。 +- 灯杆、横杆、灯壳、熄灭灯珠和倒计时牌外壳必须成为 `05_Props` 中的静态场景几何,随 + 主 GLB 导出;Cesium 只保留与这些几何严格对齐的发光灯珠、七段倒计时数字、显示开关和 + 车辆相位等待。 +- 信号锚点必须在 `intermediates` 阶段写入 `osm2streets_web_out/traffic_signals.json`,由 + Blender 与 preview 共用;不得纳入 `SCENE_LAYERS`、GeoPackage 或 QGIS 工程。 ## Acceptance Criteria -- [ ] 重建目标区域的 preview 后,Cesium 中可见至少一组位于路口进口侧的信号灯,位置与停止线/ +- [ ] 仅运行 `intermediates,blender,cesium` 后,主 GLB 已包含位于路口进口侧的灯杆、横杆、 + 灯头、熄灭灯珠和倒计时牌外壳;静态几何位置与停止线/ 斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。 - [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。 - [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。 - [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的 路线保持原有循环巡航,不因信号锚点缺失而卡住。 - [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。 +- [ ] preview 重建后,动态灯珠和数字与 GLB 中相应灯头、倒计时外壳对齐,且无 Cesium + 重复的杆、横杆、灯壳或外壳实体。 ## Notes diff --git a/assets/fonts/7LED-1.ttf b/assets/fonts/7LED-1.ttf new file mode 100644 index 0000000..80d3e3f Binary files /dev/null and b/assets/fonts/7LED-1.ttf differ diff --git a/assets/fonts/SOURCES.md b/assets/fonts/SOURCES.md new file mode 100644 index 0000000..5000b4a --- /dev/null +++ b/assets/fonts/SOURCES.md @@ -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. diff --git a/blender/export_cesium.py b/blender/export_cesium.py index b6df908..73beb9f 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -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"], diff --git a/blender/generate_scene.py b/blender/generate_scene.py index c0d6153..3461ab3 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -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), diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py index 1c10631..764d9cf 100644 --- a/blender/osmassets/catalog.py +++ b/blender/osmassets/catalog.py @@ -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)}}, } diff --git a/blender/osmassets/traffic_signals.py b/blender/osmassets/traffic_signals.py new file mode 100644 index 0000000..c417312 --- /dev/null +++ b/blender/osmassets/traffic_signals.py @@ -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)) diff --git a/blender/tests/test_traffic_signals.py b/blender/tests/test_traffic_signals.py new file mode 100644 index 0000000..cb313d5 --- /dev/null +++ b/blender/tests/test_traffic_signals.py @@ -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() diff --git a/scripts/build-area.js b/scripts/build-area.js index 8ee4d0b..b976f9b 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -182,6 +182,7 @@ function buildIntermediates(area) { "--config", derivedConfigPath, ], "intermediates"); + writeTrafficSignals(area); fs.rmSync(stageManifestPath(area, "reimport"), { force: true }); const finished = Date.now(); writeStageManifest(area, { @@ -199,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), @@ -221,6 +223,7 @@ function reimportGpkg(area) { "--config", derivedConfigPath, ], "reimport"); + writeTrafficSignals(area); fs.rmSync(stageManifestPath(area, "intermediates"), { force: true }); const finished = Date.now(); writeStageManifest(area, { @@ -238,6 +241,7 @@ function reimportGpkg(area) { outputs: { geojsonDir: fileRecord(area.outputs.geojsonDir), ...sceneGeojsonRecords(area), + trafficSignals: fileRecord(area.outputs.trafficSignals), }, summary: { geojson: geojsonFeatureCounts(area), @@ -249,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 }); @@ -290,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), @@ -324,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(); @@ -344,6 +359,7 @@ function exportCesium(area) { outputs: { glb: fileRecord(area.outputs.glb), metadata: fileRecord(area.outputs.metadata), + trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb), semanticAssets, }, summary: { @@ -466,19 +482,19 @@ 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(); fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); writeVehicleRoute(area); - writeTrafficSignals(area); const vehicleModelNames = writeVehicleModel(area); writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); const glbName = path.basename(area.outputs.glb); 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, 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}`); const finished = Date.now(); writeStageManifest(area, { @@ -516,6 +532,10 @@ function writeTrafficSignals(area) { 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 }); diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index a717a60..eda1ce9 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -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,7 +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`)), - 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, stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")), }; diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 83bb219..ea7af1c 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -108,7 +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.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, }; } diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 7c596e7..f36cc08 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -41,7 +41,7 @@ setLoadingMessage("Loading model", config.glbName || ""); const assets = await loadSceneAssets(viewer, metadata, placement); 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 cameras = createCameraPresets(viewer, metadata, placement, cruise); @@ -297,7 +297,7 @@ toggleVehicles.addEventListener("change", () => { for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked; }); - if (!trafficSignals.entities.length) { + if (!trafficSignals.count) { signalsControl.classList.add("hidden"); } else { toggleSignals.addEventListener("change", () => { @@ -464,145 +464,91 @@ }; } - function addTrafficSignals(viewer, signalData, start) { - const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude)); - const entities = []; - for (const signal of anchors) { - const mastReach = Number(signal.mastReachMeters) || 4.5; - const countdownOffset = -1.15; - const polePosition = signalPosition(signal, 0, 0, 3.35); - const headPosition = signalPosition(signal, 0, -mastReach, 6.25); - const frame = signalHeadFrame(signal, headPosition); - const pole = viewer.entities.add({ - position: polePosition, - 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, - // rather than protruding beyond the signal on the roadway side. - const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0); - const counter = viewer.entities.add({ - position: counterPosition, - orientation: frame.orientation, - box: { - dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56), - material: Cesium.Color.fromCssColorString("#251f1c"), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), - }, - }); - const countdown = createSevenSegmentCountdown( - viewer, - signal, - start, - headPosition, - frame, - countdownOffset, - ); - entities.push(counter, ...countdown); + 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: anchors.length, - set show(value) { for (const entity of entities) entity.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; + return { entities: [], count: 0, set show(value) {} }; } function signalColor(group, state, time, start) { @@ -620,7 +566,11 @@ } 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 (second < 8) return { active: "green", remaining: Math.ceil(8 - second) }; if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) }; diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index 79034c3..6e1064f 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -5,6 +5,32 @@ 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) => { @@ -37,9 +63,36 @@ function buildTrafficSignals(stopLines, intersections) { 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: 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) { @@ -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]; } -module.exports = { buildTrafficSignals, readTrafficSignals }; +module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals }; diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index 971c652..a7f62e8 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -36,6 +36,10 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-")); const input = path.join(tempDir, "input.osm"); fs.writeFileSync(input, ""); 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 } }), diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 33eb393..3896773 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -127,6 +127,23 @@ 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), @@ -138,10 +155,13 @@ const signals = buildTrafficSignals( ]] } }, ] }, ); -assert.equal(signals.version, 1); +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.");