20 Commits

Author SHA1 Message Date
96a99a671b chore: record journal 2026-08-08 15:26:42 +08:00
e89b649256 chore(task): archive 08-08-cesium-lane-centered-route 2026-08-08 15:25:29 +08:00
5658e7337d fix: 修正 Cesium 巡航车道中心对齐 2026-08-08 15:21:49 +08:00
aeb2cec021 fix(preview): retain signal poles in building ghost mode 2026-08-07 15:51:40 +08:00
a749abf00c fix(preview): retain signals in building ghost mode 2026-08-07 15:18:13 +08:00
8237c7ed27 feat(preview): add transparent building mode 2026-08-07 13:51:47 +08:00
808266127d revert: keep inspection semantic layers visible 2026-08-07 13:32:17 +08:00
22f306249d feat(preview): default inspect mode to roads 2026-08-07 13:23:33 +08:00
d382bc4462 chore: record journal 2026-08-07 12:55:48 +08:00
208ed055ac chore(task): archive 08-07-qgis-traffic-signal-overrides 2026-08-07 12:51:10 +08:00
e153a1c57d feat(qgis): add editable traffic signal assemblies 2026-08-07 12:48:38 +08:00
72fa04ddeb chore: record journal 2026-08-07 09:06:44 +08:00
1c077a312e fix(preview): derive signals from OSM controls 2026-08-07 09:05:27 +08:00
950cd1c4cd fix(preview): align traffic-aware vehicles to roads 2026-08-06 17:01:36 +08:00
2afab19463 chore: record journal 2026-08-06 17:01:36 +08:00
c930dff6c4 chore(task): archive 08-05-cesium-traffic-signals 2026-08-06 17:01:36 +08:00
043766b84e feat: add cesium traffic signal countdowns 2026-08-06 17:01:36 +08:00
9fbc218e10 feat(preview): pause vehicles for traffic signals 2026-08-06 17:01:36 +08:00
8b3a1d79d9 feat(preview): add traffic signal visualization 2026-08-06 17:01:36 +08:00
c8ca009d13 fix: 支持 Linux 本机构建环境 2026-08-05 17:11:05 +08:00
64 changed files with 3879 additions and 311 deletions

View File

@@ -205,6 +205,28 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
模型保持在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSON 配合
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。
### WGS84 ENU 坐标契约
Blender 中所有经纬度几何必须通过 `osmassets.osm.Projector` 转换。该转换必须与 Cesium
`eastNorthUpToFixedFrame(anchor)` 使用同一个 WGS84 椭球语义:先将经纬度转换为
ECEF再将相对锚点的向量投影到 East/North 轴。禁止用固定 `111320 m/deg`
equirectangular等距圆柱近似生成场景坐标。
固定米/度近似只会在锚点附近碰巧重合;纬向比例与 WGS84 实际比例不同,误差会随离锚点
距离增长。表现为 Cesium Entity 路线在部分道路居中、在其他道路相对整个 GLB 路面同向
平移。只验证 route 与 GeoJSON 自洽无法发现此问题,必须重新生成
`blender,cesium,preview` 并在最终 Cesium 画面中核对。
修改 `Projector` 后至少执行:
```bash
python3 -m unittest blender.tests.test_pure
npm run build:area -- --config config/areas/<area>.json --stages blender,cesium,preview
```
`blender.tests.test_pure.ProjectorTest` 必须断言锚点为原点、East/North 方向正确,以及局部
经纬度增量符合 WGS84 椭球曲率半径。
### Cesium contract
新生成场景的 Cesium 导出调色写在 `catalog.MATERIALS[*]["cesium"]`,由
@@ -224,6 +246,29 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
`EXPORT_BASE_COLOR_OVERRIDES``EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表,
但它们只是旧 `.blend` 兼容回退。新材质不要只写旧表。
### 交通信号倒计时字体
`assets/fonts/7LED-1.ttf` 是项目纳入版本管理的倒计时字体。它的字形是反向轮廓:可见
的 LED 段是字体轮廓里的孔,而不是普通实心文字。因此 Blender 侧不能直接把文字曲线
转成普通填充面(会得到“发光背景+黑色数字”),也不能依赖曲线描边。正确做法是在
`blender/osmassets/traffic_signals.py` 中采样负 Bezier 轮廓,构造带前后盖面的挤出棱柱,
使 LED 段成为实心发光几何。数字 mesh 必须先在 Blender 中单独渲染确认,再进入 Cesium
导出导出器出现“Could not calculate tangents”只表示这些无 UV 的纯色网格没有切线,
不等同于倒计时集合为空或几何失败。
### 共享与拆分动态资产
倒计时数字按 phase group 共享 20 个数字 mesh0-19不要按信号灯复制网格。Cesium
阶段必须生成三个动态 GLB`traffic-signals-dynamic.glb` 只含灯珠,
`traffic-signals-countdown-0.glb``traffic-signals-countdown-1.glb` 分别含两个相位组的
倒计时节点。两个倒计时模型与灯珠模型使用同一个 `modelMatrix`,浏览器只切换当前数字
节点,并给整个倒计时模型设置 `color` + `ColorBlendMode.REPLACE`,从而让字色跟随当前
红/黄/绿相位且不增加每个灯的材质/几何副本。
导出器按完整材质名包含 `Countdown Group 0` / `Countdown Group 1` 判断分组;不能用
集合名的精确相等比较,否则实际材质名 `Traffic Signal Countdown Group 0` 会被误判为
空集合。
### 为什么新资产总是"发黑"
`export_cesium.py:38-54` 记录了这个反复出现的问题:
@@ -255,9 +300,12 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 |
| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 |
| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 |
| 用固定米/度比例投影经纬度 | GLB 与 Cesium Entity 随离锚点距离产生位置漂移 |
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |
| 直接用 Cesium `Model.getMaterial().setValue()` 改普通 glTF PBR 材质 | 运行时数字仍保持原色,不能实现相位字色 |
| 每个信号灯各自生成 0-19 全套倒计时 mesh | 节点和几何按信号数量线性膨胀;应按两个 phase group 共享 |
## 第三方资产导入的源文件边界

View File

@@ -61,7 +61,7 @@
| | `generate_scene.py` | `export_cesium.py` |
|---|---|---|
| 行数 | 895 | 647 |
| 调用 | `--background --factory-startup --python` | `--background --python` |
| 调用 | `--background --factory-startup --python` | `--background --factory-startup --python` |
| 输入 | `--osm` + `--geojson`(可选) | `--blend` |
| 输出 | `--output`(.blend)、`--render`(.png) | `--glb``--metadata`(.json) |
| 完成标记 | `SCENE_DONE` | `CESIUM_EXPORT_DONE` |
@@ -70,9 +70,9 @@
两个 stdout 标记是 [parity 契约](../guides/artifact-parity-guide.md)的一部分
`scripts/parity.js:121` 解析它们),**改动打印格式等于改动契约**。
### `--factory-startup` 只在 generate 阶段用
### 后台阶段使用 `--factory-startup`
它屏蔽本机 Blender 的 preferences 和 addon保证场景生成不受用户配置影响。
它屏蔽本机 Blender 的 preferences 和 addon保证场景生成与 Cesium 导出不受用户配置影响。
副作用是脚本自己的目录不在 `sys.path` 上,所以两个入口开头都有那段
`sys.path.insert` 样板 + `# noqa: E402`——**不是可以整理掉的坏味道**。

View File

@@ -165,6 +165,16 @@ def test_spacing_carries_across_segment_joins(self):
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`
放进 `geom.py` 就立刻获得测试覆盖的资格。
### 以 MeshBatch 为边界的静态设施测试
少数 bpy 要素模块的价值在于确定性地向 `MeshBatch` 追加顶点与面,而不是调用 bpy API
本身。对这类模块(例如 `osmassets/traffic_signals.py`),应在 `blender/tests/` 用假的
`osmassets.mesh.MeshBatch` 导入模块,断言有效输入的装配数量和关键几何方向。这样可覆盖
“校验函数意外返回空、所有要素被静默跳过”这一类错误,不必依赖可用的 Blender 进程。
测试必须在本文件列出的 `python3 -m unittest discover blender/tests` 命令下独立运行;测试
文件自己添加 `blender/``sys.path`,不能依赖其他测试的导入顺序。
---
## 反模式

View File

@@ -46,6 +46,7 @@ cesium-preview.js 浏览器
- [ ] 你在改任何被 `execFileSync` / `spawnSync` 调起的东西
- [ ] 你在改 stage 的 stdout 打印
- [ ] 你要新增一种在 Blender 里生成、要在 Cesium 里看的资产
- [ ] Blender GLB 与 Cesium Entity、polyline 或 label 必须在地理位置上重合
---
@@ -144,6 +145,36 @@ OSM way 的端点不一定在原始 XML 中有三个以上相连 wayosm2stree
**教训****跨阶段契约必须随产物保存;兼容旧产物的字符串回退也要被审查**。
### 坑 6部分构建复用了过期的交通信号运行时数据
`intermediates` 才能根据当前 OSM 的 `highway=traffic_signals` 控制节点、
`vehicle_stop_lines.geojson``intersection_surface.geojson` 初始化可编辑的
`traffic_signal_assemblies.geojson`。此后该 GeoJSON 是 QGIS 编辑生命周期内的事实源;
`traffic_signals.json` 只是严格校验后派生的运行时数据。Blender 将其中每个稳定的
`signal_uid` 导出为静态设施、三个动态灯节点和一组倒计时节点,
`scripts/lib/cesium-preview.js` 再按相同 id 控制它们。
因此执行 `blender,cesium,preview` 这类部分构建时,必须在 Blender stage 入口从当前
`traffic_signal_assemblies.geojson` 重建运行时 JSON但绝不能重新从 OSM 初始化位置,
否则会覆盖 QGIS 中移动、旋转或禁用设施的编辑。该刷新由
`scripts/build-area.js:buildBlenderScene()` 负责。
**教训****跨阶段运行时 JSON 必须在最早消费它的 stage 从当前权威产物重建;同时要
区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。**
### 坑 7GeoJSON 内部正确,但 GLB 与 Cesium 路线仍然错位
巡航路线曾经与 osm2streets Driving polygon 中轴逐点吻合到厘米级,仍在最终预览中出现
部分路段偏离车道中心。原因是路线由 Cesium 直接按 WGS84 经纬度放置,而 Blender GLB
使用固定 `111320 m/deg` 的近似投影后再放到 WGS84 ENU 锚点。两套坐标仅在锚点附近
重合,误差随距离增长。
**教训**:跨坐标运行时不能只验证源数据内部自洽。凡是 Blender GLB 与 Cesium Entity
需要重合,必须检查 `GeoJSON -> Blender local ENU -> GLB modelMatrix`
`GeoJSON -> Cesium Cartesian3` 的端到端契约,并在最终画面做横截面对齐验证。
→ [资产生成WGS84 ENU 坐标契约](../blender/asset-generation.md#wgs84-enu-坐标契约)
---
## 加东西时的检查清单

View File

@@ -343,6 +343,89 @@ out.vehicleStopLines = crosswalkData.stopLines;
// 原生 lane_markings 停止线不得复制到输出。
```
## 可编辑信号设施与运行时锚点的跨阶段消费
### 1. Scope / Trigger
路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用
`<geojsonDir>/traffic_signal_assemblies.geojson` 是 GeoPackage/QGIS 中的附属可编辑点图层;
`<geojsonDir>/traffic_signals.json` 是从它严格校验并派生的运行时产物。前者不属于九个
`SCENE_LAYERS`,后者不进入 GeoPackage。
### 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
- `intermediates` 从 OSM control、停止线和路口面初始化 `traffic_signal_assemblies.geojson`
并将其作为附属点层导入 GeoPackage完整重跑 intermediates 会像道路图层一样覆盖人工编辑。
- `reimport` 必须与九个场景层一起暂存导出附属层,先校验全部信号要素,再替换任何输出。
- `build-area.js:writeTrafficSignals()` 只从当前 `traffic_signal_assemblies.geojson` 重建运行时
`traffic_signals.json`。Blender 入口也执行这一步,但不得重新从 OSM 初始化位置。
- `blender``preview` 在启动前必须检查该文件存在;前者把静态设施写进 `05_Props`
后者只叠加动态灯珠、倒计时和车辆相位。
- `traffic_signal_assemblies.geojson` 必须加入 GeoPackage/QGIS 工程,但不得加入
`SCENE_LAYERS`、合并道路场景或栅格预览;`traffic_signals.json` 仍不得加入 GeoPackage。
- `signal_uid` 必须由 control id、source way id 和相邻 arm node id 确定性生成;运行时 `id`
使用该技术 id。`display_id` 可编辑且非空时唯一,修改它不得重命名 GLB 节点。
- Point 几何是灯杆地面点;`stop_lon`/`stop_lat` 独立保存,移动杆件不得移动车辆停止点。
- `enabled=false` 的要素保留在编辑层但不进入运行时 signals。
- `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` |
| `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 |
| 用户仅修改 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
@@ -663,6 +746,9 @@ runtime 与 HTML并写 preview manifest。预览内容实现不得回流到
- `inputs.osm`
- `inputs.glb`
- `inputs.metadata`
- `inputs.lanePolygons`
- `inputs.network`
- `inputs.intersectionSurface`
- `inputs.previewCss`
- `inputs.previewJs`
- `outputs.cesiumPreview`

View File

@@ -125,10 +125,10 @@ SCENE_LAYERS.forEach((layer, index) => {
| 阶段 | 参数 | 出处 |
|---|---|---|
| `blender` | `--background --factory-startup --python generate_scene.py --` | `build-area.js:242-247` |
| `cesium` | `--background --python export_cesium.py --` | `build-area.js:276-280` |
| `cesium` | `--background --factory-startup --python export_cesium.py --` | `build-area.js:276-280` |
**`--factory-startup` 只在 generate 阶段用**:它屏蔽用户的 preferences 和 addon
保证场景生成不受本机 Blender 配置影响。export 阶段不带,因为它要读已经建好的 `.blend`
**两个后台阶段都使用 `--factory-startup`**:它屏蔽用户的 preferences 和 addon
保证场景生成与导出不受本机 Blender 配置影响。export 阶段仍会显式读取已经建好的 `.blend`
`--` 之后才是脚本自己的参数Blender 不解析它们。脚本侧用
`sys.argv[sys.argv.index("--") + 1:]` 取。

View File

@@ -112,6 +112,9 @@ setLoadingMessage("Preparing view")
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
```
预览加载的生成式 JSON路线和交通信号使用 `fetch(..., { cache: "no-store" })`,因为
这些文件保持稳定文件名但会被单独重生成;浏览器不得继续显示旧的巡航路线。
调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。
---
@@ -201,6 +204,20 @@ GLB 停留在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSO
`scenePlacement(metadata)``:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。**
## 交通信号动态覆盖层
metadata 的动态资产契约如下:
- `category="dynamic"`:灯珠节点,继续按相位切换红/黄/绿 lens 的 `show`
- `category="countdown"``phaseGroup``0``1`:对应相位组的倒计时模型;模型内
共享 20 个数字节点,不按每个信号复制数字。
三个模型必须使用完全相同的 `placement.modelMatrix`。倒计时颜色只能通过模型级
`model.color` 配合 `Cesium.ColorBlendMode.REPLACE` 设置;普通 glTF PBR 材质的
`getMaterial().setValue()` 在本项目验证中不能可靠修改运行时字色,禁止作为实现路径。
倒计时数字的显示逻辑只改变当前数字节点的 `show`,颜色由该 phase group 的当前灯色
统一设置。加载失败属于部分资产失败:应进入诊断而不清空主场景。
## 语义检查资产
### 1. 范围与触发条件

View File

@@ -11,9 +11,9 @@
## 2. Signatures
```js
buildVehicleRoute(osmPath) => {
source, bounds, generatedAt, speedMetersPerSecond, loop,
routes, segments
buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) => {
source, laneSource, networkSource, intersectionSource, bounds, generatedAt, speedMetersPerSecond, loop,
routes, segments, diagnostics
}
allowedTurns(tags, direction) => Set<"left" | "through" | "right">
@@ -28,7 +28,14 @@ classifyConnection(incomingEdge, outgoingEdge) =>
- `routes` 是当前主字段;`segments` 必须是同一数组的兼容别名,供旧预览使用。
- 每个路线至少包含 `id``coordinates``centerlineCoordinates``lengthMeters`
`maneuvers``edgeIds``coordinates` 是右侧车道偏移后的闭合巡航轨迹
`maneuvers``edgeIds``laneSegments``connectors`。道路区间来自匹配的 Driving lane polygon 中轴
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
- connector 必须绑定同一个 internal intersection并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
- preview 必须将 `lane_polygons.geojson``network.json``intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
完全一致,最终画面仍会随离锚点距离产生横向偏移。
- 单条路线无法可靠匹配时跳过并写结构化 `diagnostics`,不得回退固定或默认车道宽度。
-`oneway=yes`(及等价真值)的 way 只能按 OSM 原始方向生成 edge绝不能生成反向
`:backward` edge`oneway=-1` 仅允许反向 edge。
- 去程在路口按入边方向读取 `turn:lanes:forward``turn:lanes:backward`,只有标签中的
@@ -36,6 +43,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
- 返程是展示路线的原路回返,不以反向 `turn:lanes` 再次否决,但依旧不可逆行单行道。
- 路网没有闭环时,在去程和返程端点插入平滑调头曲线;不得在 way 端点或路口瞬移。
- 选择菜单使用 `#编号 · 长度 m · 左 N / 右 N / 直 N`,因为一条路线可跨越多个道路名称。
- 预览只显示当前下拉框选中车辆的 route polyline避免多条闭环轨迹在路口重叠造成错误的偏移判断。
## 4. Validation & Error Matrix
@@ -62,6 +70,8 @@ classifyConnection(incomingEdge, outgoingEdge) =>
`node --check scripts/lib/cesium-preview.js`:保证 Node 与浏览器直载脚本语法可用。
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`,确认
`routes` 中存在左、右、直动作,且 Cesium 下拉标签显示编号、长度与动作统计。
- 修改地理投影时必须运行 `blender,cesium,preview`,不能只重跑 preview最终检查青色路线
到黄色中心线及道路边缘的横截面距离,确认两侧路线分别位于各自车道中心。
## 7. Wrong vs Correct
@@ -89,3 +99,22 @@ edges.push(makeEdge(way, refs.reverse(), coords.reverse(), "backward"));
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
if (!isOneWay(oneway)) edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
```
## 信号动态 GLB 契约
`traffic_signals.json``pose.*` 是 Blender 静态设施、动态灯珠和倒计时共享的锚点。Blender
把发光灯珠导出为独立的 `*-traffic-signals-dynamic.glb`preview 必须使用与主 GLB 相同的
`scenePlacement(metadata).modelMatrix` 加载它Cesium 仅按命名灯珠节点切换 `show`。倒计时
例外:它由 Cesium Entity 从 `pose.countdown` 的 ENU 坐标与面向直接绘制,避免 glTF 轴变换
反转七段字形。
动态表面不能与静态镜片或倒计时外壳共面:镜片和数码管必须沿本地 `face` 轴前移
`(static_depth + dynamic_depth) / 2 + epsilon`。这是模型局部几何关系,不是经纬度修正;
否则静态网格会通过深度测试遮住发光状态,表现为灯不切换或数字不可见。
错误:在 Cesium 用 `fromDegrees`/Entity 重新计算动态设施,或将动态网格中心与静态表面中心
重合。
正确Blender 生成命名节点 `TrafficSignalDynamic_<signal-id>_<state>`;浏览器在同一 model
matrix 下加载该 GLB并只切换这些灯珠节点。倒计时 Entity 使用 `pose.countdown` 的经纬度、
高度、`faceHeadingDegrees` 生成与牌面相同的 ENU 坐标轴。

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

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

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,42 @@
# 实施计划Cesium 路口信号灯可视化
1. 复用 `traffic-signals.js` 的锚点推导,在 `intermediates` 阶段把附属
`traffic_signals.json` 写入 `geojsonDir`;不修改 `SCENE_LAYERS`、GeoPackage 或 QGIS。
2.`blender/osmassets/traffic_signals.py` 用共享低多边形 MeshBatch 几何装配静态信号设施,
并在 `generate_scene.py` 读取锚点、投影坐标、置入 `05_Props` 和写入计数。
3.`catalog.MATERIALS` 末尾追加信号设施材质和 Cesium 导出补偿,保证新 GLB 在 Cesium
中不会发黑或材质索引漂移。
4. 让 preview 直接读取 intermediates 的锚点文件;删除 Cesium 对立杆、横杆、灯头、熄灭灯珠
和外壳的构造,只保留精确对齐的动态灯珠、数字与既有相位/车辆等待。
5. 更新 Node 与纯 Python 测试,构建目标区域并用 parity 检查 GLB 差异只包含预期新增设施;
人工核对主 GLB 静态构件和 preview 动态覆盖层。
## 验证
```bash
npm run test:preview-assets
node --check scripts/lib/cesium-preview.js
python3 -m unittest discover blender/tests
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates,blender,cesium,preview
git diff --check
```
## 已验证决策与故障记录
- `assets/fonts/7LED-1.ttf` 已纳入版本管理;该字体是反向 LED 轮廓。直接填充文字会得到
绿色背景/黑色字,曲线描边会得到空心描边字;当前实现改为采样负轮廓并构造实心挤出
棱柱,独立 Blender 渲染已确认数字形状正确。
- 为支持相位字色且避免复制几何,动态导出拆为灯珠 GLB + countdown group 0/1 两个 GLB。
预览三者共用同一 placement倒计时模型使用 `colorBlendMode=REPLACE` 做模型级换色。
- 失败方案Cesium `Model.getMaterial().setValue()` 修改普通 glTF PBR uniforms用户实测
数字仍为绿色,不能恢复使用。
- 失败陷阱:导出器的 `groups` 是完整材质名集合,不能检查精确字符串 `"Countdown Group 0"`
必须用 `"Countdown Group 0" in name` 的包含判断,否则导出阶段报
`Traffic countdown collection 0 is empty`
- glTF 的 `Could not calculate tangents` 警告来自无 UV 的纯色倒计时 mesh只要三个 GLB
均成功生成,它不是阻断错误。
## 回退
删除静态信号 Blender 模块、附属锚点输出和浏览器动态覆盖层QGIS、道路/标线和既有路线
JSON 不受影响。

View File

@@ -0,0 +1,55 @@
# Cesium 路口信号灯可视化
## Goal
在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、
转向箭头和车辆巡航有清晰的交通控制参照。车辆应在对应停止线前遵守同一套相位,并在
绿灯放行后继续巡航。
## Confirmed Facts
- `scripts/lib/cesium-preview.js` 目前只加载场景资产、巡航路线与车辆;没有信号灯图层、
模型或相位状态。
- 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。
- 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补
边界、道路生成与既有连续路线逻辑。
- Cesium 当前同时绘制灯杆、横杆、灯头、熄灭灯珠、倒计时外壳,以及随相位变化的灯珠和
七段数字。这使静态设施只存在于验证层,难以随主场景维护。
- `05_Props` 集合已经进入主 `.blend` 和 Cesium GLB`traffic-signals.js` 已是停止线和
路口面推导信号锚点的唯一事实源。
## Requirements
- 在道路进口侧、停止线附近呈现简洁而可辨识的交通信号灯,并在不遮挡车道标线的前提下
面向来车方向。
- 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。
- 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。
- 车辆接近已匹配的停止线时,红灯和黄灯必须停车,绿灯继续通行;相位切换后应自然恢复
移动,不得瞬移到路口另一侧。
- 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。
- 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠
几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。
- 灯杆、横杆、灯壳、熄灭灯珠和倒计时牌外壳必须成为 `05_Props` 中的静态场景几何,随
主 GLB 导出Cesium 只保留与这些几何严格对齐的发光灯珠、七段倒计时数字、显示开关和
车辆相位等待。
- 信号锚点必须在 `intermediates` 阶段写入 `osm2streets_web_out/traffic_signals.json`,由
Blender 与 preview 共用;不得纳入 `SCENE_LAYERS`、GeoPackage 或 QGIS 工程。
## Acceptance Criteria
- [ ] 仅运行 `intermediates,blender,cesium` 后,主 GLB 已包含位于路口进口侧的灯杆、横杆、
灯头、熄灭灯珠和倒计时牌外壳;静态几何位置与停止线/
斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。
- [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。
- [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。
- [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的
路线保持原有循环巡航,不因信号锚点缺失而卡住。
- [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。
- [ ] preview 重建后,动态灯珠和数字与 GLB 中相应灯头、倒计时外壳对齐,且无 Cesium
重复的杆、横杆、灯壳或外壳实体。
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

@@ -0,0 +1,26 @@
{
"id": "cesium-traffic-signals",
"name": "cesium-traffic-signals",
"title": "Cesium 路口信号灯可视化",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-05",
"completedAt": "2026-08-06",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,5 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Check stage ownership, diagnostics, and traffic signal contract compliance"}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"Check atomic reimport behavior and external-tool handling"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Check full editable-layer to runtime JSON to Blender/Cesium data flow"}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Check intended and unintended scene/GLB structural differences"}
{"file":".trellis/spec/blender/testing.md","reason":"Check appropriate pure and Blender validation coverage"}

View File

@@ -0,0 +1,100 @@
# Design: QGIS Traffic Signal Overrides
## Architecture
Introduce a separate auxiliary-edit-layer registry rather than adding traffic signals to `SCENE_LAYERS`. The initial registry contains one layer:
```text
traffic_signal_assemblies.geojson
geometry: Point (pole ground position, EPSG:4326)
properties: stable identity, source identity, heading, phase, reach, stop point, enabled, z offset
```
The existing runtime file remains:
```text
traffic_signals.json
version/layout/signals[] with full pose.* data
```
The editable GeoJSON is the placement source; the runtime JSON is a derived consumer artifact.
## Data Flow
```text
OSM controls + topology + stop lines + intersections
|
intermediates only
v
traffic_signal_assemblies.geojson
|
import into GeoPackage
|
edit in QGIS
|
reimport
v
traffic_signal_assemblies.geojson
|
validate + derive pose
v
traffic_signals.json
/ \
Blender preview/Cesium
```
`blender` reruns the final validation/derivation arrow from the editable GeoJSON so derived JSON cannot be stale, but it never reruns the OSM initialization arrow.
## Editable Feature Contract
Recommended properties:
| Property | Type | Ownership |
|---|---|---|
| `signal_uid` | string | generated, immutable technical identity |
| `display_id` | string | user-editable unique label/number |
| `control_id` | string | generated OSM control id |
| `approach_id` | string | generated physical approach identity |
| `source_way_id` | string | generated matching/diagnostic field |
| `heading_deg` | number | user-editable assembly facing direction |
| `phase_group` | integer 0/1 | user-editable current two-phase group |
| `mast_reach_m` | positive number | user-editable arm reach |
| `stop_lon`, `stop_lat` | finite numbers | generated vehicle stop point, preserved when pole moves |
| `enabled` | boolean/integer | user-editable suppression flag |
| `z_offset_m` | finite number | user-editable vertical adjustment |
Point geometry is the pole longitude/latitude. The runtime `id` should be derived from `signal_uid`, not display numbering, so changing `display_id` does not rename GLB nodes or break preview control.
## Stable Identity
Extend parsed OSM arm data to retain enough deterministic source identity (control node, way, adjacent arm direction/node). Generate a technical key from those source values. Do not use sorted array index or rounded heading as the primary key.
If topology changes on a future `intermediates` run, the rebuilt GeoPackage may produce new identities. This is consistent with current road-edit lifecycle and is explicitly out of scope for MVP migration. Validation still reports duplicate identities and malformed source fields.
## QGIS Integration
- Add an auxiliary layer definition separate from the nine render layers.
- Import it into the same GeoPackage after render layers.
- Include it in the generated project but exclude it from the merged scene and 2D raster preview unless deliberately enabled for editing visibility.
- Use a point marker plus rotated direction indicator driven by `heading_deg` and label by `display_id`, falling back to `signal_uid`.
- Configure read-only/editor widgets where practical: technical/source ids read-only; phase group constrained to 0/1; numeric fields constrained to valid ranges; enabled as checkbox.
## Reimport and Atomicity
Extend the reimport layer manifest to include auxiliary editable layers while keeping render-scene merge derived only from `SCENE_LAYERS`. Export every layer into staging, parse and validate all editable features, then replace output files. Runtime JSON is written only after the staged auxiliary layer passes validation.
## Compatibility
- The next `intermediates` run bootstraps existing areas; no old JSON migration is required.
- Main `.blend`/GLB geometry changes intentionally when a QGIS edit changes a signal.
- Dynamic and countdown GLBs continue using runtime signal ids, now stable across ordinary reimport edits.
- Current two-phase simulation remains unchanged.
## Risks and Controls
- **OSM way splitting changes source ids:** accepted across a full intermediates rebuild; ordinary reimport is stable.
- **QGIS boolean/string coercion:** normalize known GDAL representations before strict validation and test the round-trip output.
- **Accidental source-field editing:** mark technical fields read-only in QGIS and validate identity format during reimport.
- **Partial overwrite on invalid auxiliary data:** retain the existing staging-before-replace discipline.
- **Old spec conflict:** update pipeline specs that currently forbid traffic-signal anchors in GeoPackage, clarifying the distinction between editable assembly points and derived runtime anchors.

View File

@@ -0,0 +1,6 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Traffic signal stage ownership, reimport lifecycle, manifests, and current anchor contract"}
{"file":".trellis/spec/pipeline/layer-registry.md","reason":"Keep the auxiliary editable layer separate from the nine render layers and preserve their order"}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"GeoPackage import/export and staging-before-replace requirements"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"OSM to GeoJSON/GPKG to Blender/Cesium contract review"}
{"file":".trellis/spec/blender/asset-generation.md","reason":"Signal pose and dynamic asset generation constraints"}
{"file":".trellis/spec/preview/vehicle-routes.md","reason":"Vehicle stop coordinates and runtime signal data coupling"}

View File

@@ -0,0 +1,53 @@
# Implementation Plan: QGIS Traffic Signal Overrides
## 1. Contracts and Pure Logic
- [x] Add an auxiliary editable-layer definition without modifying `SCENE_LAYERS` ordering.
- [x] Extend OSM arm parsing with deterministic approach identity inputs.
- [x] Split traffic-signal logic into automatic editable-feature generation, feature validation/normalization, and runtime pose derivation.
- [x] Use stable technical ids for runtime signal ids; keep `display_id` as editable metadata.
- [x] Add pure Node tests for T/cross counts, stable ids, movement/heading reconstruction, disabled features, duplicate ids, and invalid values.
## 2. Intermediates and QGIS
- [x] Write `traffic_signal_assemblies.geojson` after stop-line/intersection outputs are stable.
- [x] Import the auxiliary point layer into the GeoPackage after the nine render layers.
- [x] Extend generated QGIS project code with point/direction styling, labels, and field widgets/constraints.
- [x] Confirm the auxiliary layer is excluded from merged road scene ordering and raster preview behavior.
## 3. Reimport and Stage Ownership
- [x] Extend `reimport-gpkg.js` to discover/export render and auxiliary layers through staging.
- [x] Validate the staged editable layer before replacing any output artifact.
- [x] Rebuild runtime `traffic_signals.json` from editable GeoJSON after `intermediates`, `reimport`, and at Blender entry.
- [x] Remove Blender-entry OSM placement regeneration so QGIS edits remain authoritative.
- [x] Extend stage manifests and diagnostics with auxiliary input/output records and feature counts.
## 4. Cross-Layer Consumers
- [x] Preserve `display_id` and stable runtime ids through Blender and Cesium metadata where useful.
- [x] Verify static signal objects, dynamic lenses, countdown nodes, and vehicle stop behavior all consume the same enabled runtime records.
- [x] Update pipeline specifications to replace the old prohibition with the editable-layer/derived-runtime distinction.
## 5. Validation
- [x] Run Node syntax checks and focused unit tests.
- [x] Run existing preview-assets, preflight, budget, and relevant pipeline tests.
- [x] Run `intermediates` and inspect the GeoPackage/QGIS project feature schema and styling.
- [ ] Make a controlled QGIS edit to one signal (display id, point, heading), run `reimport,blender,cesium,preview`, and verify only the intended assembly changes. (`reimport` and Blender passed; Cesium/preview refresh was not repeated.)
- [x] Confirm an invalid/duplicate edit fails before overwriting valid outputs.
- [ ] Inspect Blender/Cesium structural digests and Safari preview for T and cross junctions. (Blocked this run by Blender 4.5.12 Metal startup SIGSEGV before project Python.)
## Risky Files / Rollback Points
- `scripts/lib/traffic-signals.js`: identity and pose contract; land pure tests before pipeline integration.
- `scripts/build-osm2streets-qgis.js`: GeoPackage recreation and generated QGIS Python; verify auxiliary import independently before styling.
- `scripts/reimport-gpkg.js`: atomic overwrite boundary; preserve staging semantics.
- `scripts/build-area.js`: stage ownership; ensure Blender derives from editable GeoJSON rather than overwriting it.
- `blender/osmassets/traffic_signals.py` and preview runtime should require minimal or no geometry changes; unexpected edits here indicate contract leakage.
## Review Gate Before Start
- [ ] User approves the final planning summary.
- [ ] `prd.md`, `design.md`, and `implement.md` agree on full editable layer ownership and out-of-scope intermediates persistence.
- [ ] No unresolved product decision remains.

View File

@@ -0,0 +1,14 @@
# Debug Notes
## 2026-08-07 countdown node-name regression
Stable `signal_uid` values are intentionally descriptive and can exceed Blender's
63-byte object-name limit. Using them directly in dynamic lens/countdown node
names caused Blender to truncate names while Cesium looked up the untruncated
strings. The countdown GLBs then exposed all digits without the runtime being
able to hide the inactive values, appearing as overlapping/blurred numbers.
Runtime signal records now carry a deterministic short `nodeKey` (`ts_` plus
the first 16 hex characters of SHA-256 of `signal_uid`). Blender uses it for
dynamic object names and Cesium uses the same key for lookups. Preview keeps a
fallback to `signal.id` for older metadata files.

View File

@@ -0,0 +1,67 @@
# QGIS Traffic Signal Overrides
## Goal
Make every automatically generated vehicle traffic signal independently identifiable and editable in QGIS. A user must be able to assign a display number, move a pole, rotate its assembly, adjust supported placement attributes, run `reimport`, and have Blender and Cesium consume that edited result without OSM regeneration overwriting it.
## Background
- Current traffic signals are derived from OSM `highway=traffic_signals` controls, road topology, `vehicle_stop_lines.geojson`, and `intersection_surface.geojson` by `scripts/lib/traffic-signals.js`.
- Current sequential ids such as `signal-1` depend on generation order and are not suitable as persistent edit identities.
- Current `<geojsonDir>/traffic_signals.json` contains fully derived `pose.*` data but is deliberately excluded from the GeoPackage and QGIS project.
- Existing road editing establishes the desired lifecycle: `intermediates` initializes a GeoPackage, the user edits it in QGIS, and `reimport` exports the edited data back to GeoJSON. Running `intermediates` again may discard manual edits; that behavior remains explicit and unchanged.
## Requirements
### R1. Editable auxiliary layer
- `intermediates` must create a point FeatureCollection containing one feature per physical signal assembly and import it into the area GeoPackage.
- The generated QGIS project must expose the layer with a visible directional symbol and a label suitable for identifying individual signals.
- The auxiliary layer must not join `SCENE_LAYERS` or the merged road scene because it is an editing/control artifact, not a road render layer.
### R2. Stable identity and numbering
- Every generated feature must contain an immutable technical `signal_uid` derived deterministically from its OSM control and physical approach identity, rather than array order.
- Every feature must contain an editable `display_id` intended for user-facing numbering.
- Build/reimport validation must reject duplicate or missing `signal_uid` values and duplicate non-empty `display_id` values with an actionable error.
### R3. Editable placement contract
- Point geometry represents the pole ground position.
- Editable attributes must include at least `display_id`, `heading_deg`, `phase_group`, `mast_reach_m`, `enabled`, and `z_offset_m`.
- Source/control attributes required for matching and diagnostics must be preserved, including `control_id` and approach identity.
- Vehicle stop coordinates remain independent attributes; moving the pole must not silently move the vehicle stop point.
- After reimport, the pipeline must deterministically rebuild `pose.pole`, `pose.arm`, `pose.head`, `pose.lenses`, and `pose.countdown` from the edited point and attributes.
### R4. Stage ownership
- `intermediates` initializes the editable signal layer from current OSM/topology and derives the runtime `traffic_signals.json` from it.
- `reimport` must stage, validate, and export the editable signal layer along with the existing road layers, then rebuild the runtime JSON.
- `blender` must rebuild runtime `traffic_signals.json` from the current editable signal GeoJSON. It must not recompute signal placement directly from OSM and erase QGIS edits.
- `cesium` and `preview` continue consuming artifacts derived from the same runtime JSON and retain matching signal node ids.
### R5. Diagnostics and compatibility
- Invalid geometry, invalid numeric fields, duplicate identities, unsupported phase groups, and unmatched source references must fail before replacing valid output artifacts.
- `enabled=false` suppresses a signal without requiring feature deletion, so automatic regeneration cannot accidentally resurrect an intentionally disabled assembly within the same edit lifecycle.
- Existing areas without an editable signal layer must receive one on their next `intermediates` run. No migration of previously hand-edited traffic signal JSON is required.
## Acceptance Criteria
- [ ] A clean `intermediates` run creates the editable traffic-signal GeoJSON, a GeoPackage layer with the same feature count, and a QGIS project layer with labels and directional symbols.
- [ ] T junctions produce three editable features and cross junctions produce four, each with a unique deterministic `signal_uid`.
- [ ] Moving one point in QGIS and changing its `display_id` and `heading_deg`, followed by `reimport,blender,cesium,preview`, changes only that signal assembly's placement/identity-facing metadata while preserving its vehicle stop point.
- [ ] Re-running `blender` after reimport does not overwrite the QGIS-edited pole position or heading from OSM.
- [ ] Setting one feature to disabled removes its static and dynamic signal assets while leaving the other signals intact.
- [ ] Duplicate `signal_uid` or non-empty `display_id`, invalid geometry, and invalid placement fields abort reimport without partially replacing GeoJSON outputs.
- [ ] Blender/Cesium node counts and ids match the enabled features in the final runtime JSON; lights and countdowns continue switching correctly.
- [ ] Existing road GeoPackage import/reimport behavior and merged scene layer order remain unchanged.
- [ ] Unit/integration tests cover stable ids, editable-feature validation, override-to-pose reconstruction, auxiliary GeoPackage round-trip, and stage ownership.
## Out Of Scope
- Preserving QGIS edits across a subsequent full `intermediates` rebuild; as with road edits, users must preserve or reapply edits before regenerating the GeoPackage.
- A complete traffic-controller timing editor or arbitrary multi-phase signal program.
- Independent editing of each lens or countdown glyph position; those remain derived from the assembly point, heading, and shared layout.
- Automatically assigning a stable identity to a brand-new signal feature drawn manually in QGIS.

View File

@@ -0,0 +1,26 @@
{
"id": "qgis-traffic-signal-overrides",
"name": "qgis-traffic-signal-overrides",
"title": "QGIS traffic signal overrides",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-07",
"completedAt": "2026-08-07",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,130 @@
# 自动匹配 Cesium 巡航车道中心:技术设计
## 设计目标
巡航路线以 osm2streets internal road topology 与已渲染的 Driving lane polygon 为几何事实源。道路区间直接使用 polygon 的中轴,不再对 OSM way 中心线施加固定米数偏移OSM 只提供原始标签和转向语义,不再作为最终路口拓扑。
## 数据流
```text
network.json ──▶ internal road/intersection directed graph
OSM XML ──▶ turn:lanes semantics
lane_polygons.geojson ──▶ validated Driving lane centerlines
maneuver-aware lane selection
internal road lane sections + surface-constrained junction curves
vehicle-route.json + diagnostics
```
`scripts/build-area.js` 在 preview stage 调用 `buildVehicleRoute(osmPath, lanePolygonsPath)``lane_polygons.geojson` 同时写入 preview manifest inputs`scripts/lib/area-diagnostics.js` 使用既有 SHA-256 freshness 检查自动识别过期路线。
## 模块边界
### `scripts/lib/lane-geometry.js`
新增纯几何共享模块,承载:
- 校验 Driving `Polygon` ring
- 通过 polygon 两侧对应顶点中点提取 lane centerline
- 米制距离、方向对齐、端点排序和 polyline 拼接所需的无副作用 helper。
`scripts/lib/turn-lane-arrows.js` 改为导入共享 `laneCenterline()`,确保箭头和巡航对 osm2streets polygon 顺序使用同一契约。
### `scripts/lib/vehicle-route.js`
保留现有 OSM 路线拓扑搜索,替换 `LANE_OFFSET_METERS` / `offsetClosedRouteRight()` 路径:
1. 加载并严格校验 `lane_polygons.geojson` 为 FeatureCollection。
2.`road`、lane `index``direction=Fwd|Back` 建立 Driving lane 索引;`osm_way_ids` 仅用于追溯 OSM 标签。
3. 对每条 directed edge使用实际横向位置按行驶方向排列同向车道renderer `index` 只作为稳定 tie-breaker不单独决定车道顺序。
4. 将 edge 末端的 maneuver 与 `turn:lanes:forward|backward` 对齐。左转/掉头选最左兼容车道,右转选最右兼容车道,直行选最右兼容车道;无显式 lane restriction 时按相同位置规则选择。
5. 每个 edge 对应一个 osm2streets internal road禁止把同一 OSM way 下多个 internal road 当作无语义 fragment 直接拼接。
6. 车道中轴端点之间使用 tangent Bezier / U-turn 连接,并绑定共同的 internal intersection全部采样点必须落在对应 `intersection_surface` 内或边界容差内。
7. 任一 edge 无法可靠匹配时丢弃该候选 route继续搜索其他候选最多输出 5 条。
## 输出契约
保留顶层 `routes`、兼容别名 `segments``loop``speedMetersPerSecond`,以及每条路线的 `coordinates``centerlineCoordinates``edgeIds``maneuvers``lengthMeters`
每条路线新增 `laneSegments`,每段至少记录:
- `edgeId``osmWayId``direction`
- `laneIndex``widthMeters``centerOffsetMeters`
- `maneuver``source="lane_polygon_centerline"`
- 参与拼接的 polygon/road 标识。
顶层新增 `diagnostics`,按稳定 reason code 汇总被拒绝的 edge/route例如 `missing_lane_polygon``invalid_lane_polygon``ambiguous_lane_order``no_compatible_turn_lane``disconnected_lane_fragments`。固定 `laneOffsetMeters` 不再作为几何输入;为避免伪造单值,不以平均偏移替代逐段事实。
## 错误与降级语义
- 整个 lane polygon 文件缺失、JSON 无法解析或不是 FeatureCollectionpreview stage 在写产物前失败。
- 单个 polygon 无效:记录诊断,该 polygon 不参与匹配。
- directed edge 缺少唯一可信车道:候选 route 被拒绝,生成器继续选择其他 route。
- 所有候选都被拒绝:生成合法的空 `routes` 和完整诊断Cesium 场景仍可加载,但不显示巡航车辆。
- 禁止回退到固定 `1.3 m`、固定 `1.5 m` 或默认 lane width。
## 兼容性
- Blender scene 经纬度投影改为 WGS84 ECEF→ENU与 Cesium
`eastNorthUpToFixedFrame` 的锚点坐标系一致;这是 route 与最终 GLB 道路重合的必要
跨层契约。
- `segments` 继续与 `routes` 引用相同数组。
- Cesium runtime 当前只消费 `coordinates` 等既有字段,无需理解 `laneSegments` 即可运行。
- preview manifest 新增 lane polygon input 后,旧 manifest 会被诊断为缺少记录并要求重建,这是预期迁移行为。
## 验证策略
- 纯几何测试:四边形、曲线 polygon、反向 geometry、坏 ring。
- 路线 fixture不同宽度、双向/单向、多车道和转向车道选择。
- 精度断言:路线道路区间采样点到 polygon 中轴距离不超过 `0.10 m`
- 连续性断言:车道变化和路口连接处没有由数据拼接产生的异常横跳。
- 集成验证:目标区域重跑 preview检查 route JSON diagnostics、manifest freshness、区域质量门和 Cesium 实际显示。
- 端到端视觉验证:重跑 `blender,cesium,preview`,对最终画面做道路横截面检查;不能用
route 与 lane polygon 的厘米级一致性代替 GLB/Entity 对齐验证。
## Bug AnalysisGLB 与巡航路线随距离漂移
### 1. Root Cause Category
- **Category**B - Cross-Layer ContractD - Test Coverage Gap。
- **Specific Cause**Blender 使用固定米/度的平面近似Cesium 使用 WGS84 椭球 ENU
两层没有共享坐标转换契约。
### 2. Why Fixes Failed
1. 固定车道偏移:只处理症状,且假定所有车道宽度相同。
2. 从 Driving polygon 重建中轴:解决了车道宽度与 lane 选择,但只证明 GeoJSON 内部正确。
3. 路口切线连接:改善了 connector却没有解释直线路段整套坐标同向平移。
### 3. Prevention Mechanisms
| Priority | Mechanism | Specific Action | Status |
|---|---|---|---|
| P0 | Architecture | `Projector` 使用 WGS84 ECEF→ENU与 Cesium 锚点一致 | DONE |
| P0 | Test Coverage | 测试 WGS84 局部经纬度比例与 ENU 方向 | DONE |
| P1 | Documentation | 在 Blender、preview 与 cross-layer spec 固化契约 | DONE |
| P1 | Integration | 坐标变更后强制重跑 `blender,cesium,preview` 并视觉核对 | DONE |
### 4. Systematic Expansion
- **Similar Issues**交通信号、语义模型、route polyline 等所有叠加在 GLB 上的 Cesium
Entity 都依赖同一契约。
- **Design Improvement**:坐标转换只有 `Projector` 一个 Blender 事实源。
- **Process Improvement**:跨运行时几何必须验证最终组合画面,不能停在单层数值测试。
### 5. Knowledge Capture
- [x] 更新 Blender asset generation spec。
- [x] 更新 preview vehicle route spec。
- [x] 更新 cross-layer thinking guide。
- [x] 增加 `ProjectorTest` WGS84 断言。
## 回滚
代码回滚只涉及 preview 路线生成和共享 JS helper重跑 preview 即可恢复旧路线产物,不需要重建 Blender/GLB。用户已有区域配置修改保持不动。

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,43 @@
# 自动匹配 Cesium 巡航车道中心:实施计划
## 实施顺序
- [x] 1. 新增 `scripts/lib/lane-geometry.js`,从 `turn-lane-arrows.js` 迁移并导出 Driving polygon 中轴提取与必要的纯几何 helper补充共享几何测试确认箭头行为不变。
- [x] 2. 扩展 `scripts/lib/vehicle-route.js` 输入校验和车道索引,按 OSM way、行驶方向、实际横向位置及 maneuver 选择目标 Driving lane。
- [x] 3. 实现同车道多 fragment 的方向校验、确定性拼接和 lane centerline 路线生成;删除固定偏移路径,加入稳定 reason-code diagnostics 与 `laneSegments` 溯源。
- [x] 4. 更新 `scripts/test-preview-assets.js` fixture覆盖 `3.0 m` / `3.5 m`、双向单车道、同向多车道、oneway、正反方向、左/直/右选择、坏 polygon 和缺失匹配。
- [x] 5. 修改 `scripts/build-area.js`,在任何 preview 写入前校验 `lane_polygons.geojson`,传给路线生成器并记录到 stage manifest inputs。
- [x] 6. 修改 `scripts/lib/area-diagnostics.js` 的 preview expected inputs并补充 manifest freshness 回归测试或等价断言。
- [x] 7. 更新 README 巡航说明,移除固定 `1.3 m` 描述,说明实际车道中轴、跳过语义和诊断字段。
- [x] 8. 重生成目标区域 preview 路线,检查所有 route 的 lane 溯源、空/拒绝诊断和直线路段 `<= 0.10 m` 中心误差。
- [x] 9. 将路线拓扑切换到 `network.json` internal road/intersection并对 connector 执行 `intersection_surface` 越界拒绝;预览仅显示当前选中 route等待用户做最终视觉复核。
## 当前验证状态
- 目标区域只读生成验证通过:`5` 条路线、`56` 个 lane segment、`116` 个中轴顶点最大误差 `0 m`,覆盖 left/right/through/u_turn实际 center offset 范围 `1.4151.588 m`
- 第 8、9 步暂未完成preview stage 在写产物前因既有 `traffic_signal_assemblies.geojson``traffic_signals.json` 缺失而失败。
- `check:area` 的 3 个 failure 均来自既有 intermediates/blender/preview manifest stale修复需要重跑会重建 GeoPackage 的 intermediates未获用户授权前不执行。
## 验证命令
```bash
node --check scripts/lib/lane-geometry.js
node --check scripts/lib/vehicle-route.js
node --check scripts/lib/turn-lane-arrows.js
node --check scripts/build-area.js
npm run test:turn-lane-arrows
npm run test:preview-assets
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview
npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json
```
若仓库已有覆盖 manifest freshness 的独立测试入口,一并运行;否则在现有最接近的 Node 测试中加入定向断言。
## 风险与检查点
- polygon fragment 拼接是最高风险点:完成第 3 步后先用目标区域做只读匹配统计,确认不会因 osm2streets 分段导致全部路线被拒绝,再继续 stage 集成。
- 多车道 lane order 不得只依赖 renderer `index`;必须用行驶方向下的实际横向位置验证顺序。
- 不把 OSM `width` 或默认宽度作为无声回退;任何覆盖率下降必须能从 diagnostics 定位。
- 不修改用户已有的 `config/areas/nantaizi-lake-innovation-valley.json` Linux 路径变更。
- 不需要 GLB parity本任务不改变 Blender/GLB。但必须重跑 preview manifest 和区域质量门。

View File

@@ -0,0 +1,49 @@
# 自动匹配 Cesium 巡航车道中心
## Goal
Cesium 预览巡航路线应根据实际车道数据自动落在所选行车道中心,消除固定横向偏移带来的位置误差,为后续车辆仿真提供可靠的几何基础。
## Background
- `scripts/lib/vehicle-route.js:9` 当前使用固定 `LANE_OFFSET_METERS = 1.3`,并在 `makeRoute()` 中对整条平滑后的道路中心线统一向右偏移。
- 当前区域的 `lane_polygons.geojson` 中 Driving lane 宽度为 `3.0 m`,单车道中心距道路中心线应为 `1.5 m`,现有路线存在约 `0.2 m` 横向误差。
- `lane_polygons.geojson` 已包含 `direction``width``index``osm_way_ids` 和车道 polygon这些数据与最终渲染道路来自同一 osm2streets 中间产物。
- OSM 输入包含 `lanes``lanes:forward``lanes:backward``turn:lanes:*`,但不保证包含明确的 `width`,不能单独作为所有区域的精确宽度来源。
- 历史提交 `30846b6` 引入连续巡航路线时沿用了实验阶段的 `1.3 m` 固定值,没有建立路线与 osm2streets 车道几何之间的契约。
## Requirements
- R1路线生成以 osm2streets 实际 Driving lane 数据为主事实源,不再使用全局固定偏移常量。
- R2按 OSM way、行驶方向和车道顺序匹配目标车道并根据各路段的真实宽度及横向位置计算车道中心。
- R3不同宽度、不同车道数或不同方向配置的连续道路必须逐路段计算偏移路口连接处保持连续且不产生横向跳变。
- R4多车道路段必须选择一条明确的目标车道车道选择和转向可行性应使用 `direction``index``allowed_turns` / `turn:lanes:*` 数据,而不是只看总车道数。
- R4.1:同向多车道按下一次 maneuver 选择兼容车道;左转/掉头优先最左侧兼容车道,右转优先最右侧兼容车道,直行默认最右侧兼容车道。没有兼容车道时跳过该候选路线并记录诊断。
- R5每条输出路线记录所用车道、宽度/偏移来源及诊断信息,使下游能够识别精确匹配、次级推导和跳过的路段。
- R5.1:缺少、歧义或无法验证车道数据的路段必须跳过并输出结构化诊断;禁止回退到固定偏移或默认车道宽度。
- R6保留现有 `routes`、兼容别名 `segments`、闭环路线、信号灯停车和 Cesium 动画消费契约。
- R7preview stage manifest 将参与路线计算的车道数据列为输入,使车道几何变化能够正确判定预览产物 stale。
- R8不得修改 Blender/GLB 主资产;本任务只修正预览巡航路线及其生成契约。
## Acceptance Criteria
- [x] AC1当前南台子湖区域重新生成后普通直线路段的巡航点位于匹配 Driving lane 的几何中心,允许误差不超过 `0.10 m`
- [x] AC2测试 fixture 覆盖至少 `3.0 m``3.5 m` 两种车道宽度,输出中心偏移分别随实际车道数据变化,不存在 `1.3 m``1.5 m` 全局常量依赖。
- [x] AC3测试 fixture 覆盖双向单车道、同向多车道、`oneway=yes` 和正反方向,验证目标车道选择及偏移方向正确。
- [x] AC4连续路段车道宽度变化或车道数变化时路线在衔接区连续相邻采样点不得出现由偏移切换导致的异常横跳。
- [x] AC5路线 JSON 能追溯每段匹配到的 `osm_way_ids`、方向、lane index、width、center offset 和数据来源。
- [ ] AC6既有 preview asset tests、路线闭环测试和区域质量门通过信号灯停车与车辆朝向行为不回归。
- [x] AC7preview manifest 记录车道数据文件;该文件改变后诊断能够将 preview 标记为 stale。
## Out of Scope
- 车辆在运行时动态换道、超车或避障。
- 交通流量、车辆间距和碰撞模型。
- 修改 osm2streets 生成的车道 polygon 或 Blender 道路网格。
- 将实验性预览巡航升级为通用交通仿真引擎。
## Technical Constraints
- 本任务涉及 OSM、osm2streets GeoJSON、stage manifest 和 Cesium route JSON 的跨层契约,按复杂任务处理;规划收敛后需要 `design.md``implement.md`
- Driving lane polygon 的相对两边中点算法已在 `scripts/lib/turn-lane-arrows.js:265` 验证;巡航路线应复用同一纯几何实现,不维护第二份 polygon 解析逻辑。
- `preview` 保持可独立执行,但现在明确依赖已有 `lane_polygons.geojson`;车道文件整体缺失或格式无效属于 stage 输入错误,单个路段无法可靠匹配则跳过并写诊断。

View File

@@ -0,0 +1,23 @@
# osm2streets 车道中心线研究
## 上游实现
- 调研版本:`osm2streets-js-node 0.1.4`,上游 `osm2streets` commit `fc119c47dac567d030c6ce7c24a48896f58ed906`
- `Road::get_untrimmed_center_line()` 先根据 OSM reference line、`reference_line_placement`、道路总宽度和驾驶方向生成 road full-width centerline。
- `Road::get_lane_center_lines()``lane_specs_ltr` 从左到右累计真实 lane width再调用 `center_line.shift_from_center(total_width, width_from_left_side)` 生成每条 lane 的中心线。
- `to_lane_polygons_geojson()` 先取得上述 lane centerline再调用 `pl.make_polygons(lane.width)` 生成 Driving polygon。因此 lane polygon 是 lane centerline 的派生产物。
- `PolyLine::make_polygons()` 使用两侧等距平移和 miter 交点生成 polygon ringring 前半边与反向后的后半边一一对应,其中点可恢复原始 lane centerline。
## 当前区域审计
- `54` 个 Driving polygon`32` 个四边形,其余包含 `6/8/10/18` 个非闭合顶点。
- 使用 `network.json``road.center_line``lane_specs_ltr` 和 geom miter 算法重建所有 lane centerline。
-`scripts/lib/lane-geometry.js:laneCenterline()` 结果逐点比较,最大误差为 `0.006 m`
- 结论:道路区间的 polygon 中轴提取与 osm2streets 权威 lane centerline 一致,不是肉眼所见大偏移的来源。
## 路口 movement 限制
- osm2streets 的公开 JS API 没有导出可直接用于车辆行驶的 lane-to-lane movement centerline。
- `debugMovementsFromLaneGeojson()` 只是调试箭头:在双向 road centerline 上使用固定 `1.3 m` 偏移,再用直线连接 road endpoints它不是 lane-aware 仿真轨迹,不能复用。
- 当前项目的大偏移排查应限定在selected lane 与 internal road 的映射、junction connector、U-turn以及多条闭环 route 同时显示造成的视觉混淆。
- 后续 connector 必须显式关联 `network.json` 的 internal road/intersection并受 `intersection_surface.geojson` 约束;不得修改已验证的 lane section centerline。

View File

@@ -0,0 +1,26 @@
{
"id": "cesium-lane-centered-route",
"name": "cesium-lane-centered-route",
"title": "自动匹配 Cesium 巡航车道中心",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "que01",
"assignee": "que01",
"createdAt": "2026-08-08",
"completedAt": "2026-08-08",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 24
- **Last Active**: 2026-08-05
- **Total Sessions**: 27
- **Last Active**: 2026-08-07
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~512 | Active |
| `journal-1.md` | ~575 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,9 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 27 | 2026-08-07 | QGIS traffic signal editing and countdown stability | `e153a1c` | `main` |
| 26 | 2026-08-07 | 交通信号拓扑与部分构建同步修复 | `1c077a3` | `main` |
| 25 | 2026-08-06 | Cesium traffic signal countdowns | `0e1574f` | `main` |
| 24 | 2026-08-05 | 拆分 LowPoly Cars 车辆资产 | `3108336`, `2489b8a` | `main` |
| 23 | 2026-08-05 | 车辆连续巡航与转弯 | `30846b6` | `main` |
| 22 | 2026-08-05 | Cesium semantic inspection preview | `607d8fc` | `main` |

View File

@@ -510,3 +510,66 @@ Added semantic Cesium inspection assets and controls; fixed compressed metadata
### Status
[OK] **Completed**
## Session 25: Cesium traffic signal countdowns
**Date**: 2026-08-06
**Task**: Cesium traffic signal countdowns
**Branch**: `main`
### Summary
Added shared 7LED countdown geometry, split dynamic Cesium assets by phase group for reliable lamp-matched colors, documented the inverse-font and exporter grouping contracts, and verified the preview asset and Blender Python test suites.
### Git Commits
| Hash | Message |
|------|---------|
| `0e1574f` | (see git log) |
### Status
[OK] **Completed**
## Session 26: 交通信号拓扑与部分构建同步修复
**Date**: 2026-08-07
**Task**: 交通信号拓扑与部分构建同步修复
**Branch**: `main`
### Summary
基于 OSM highway=traffic_signals 控制节点生成 T/十字路口信号,复用共享 OSM 解析Blender stage 每次重写交通信号锚点,避免部分构建沿用旧 JSON 导致 GLB 与 Cesium 预览 signal id 错位。验证了 nantaizi 区域 35 盏信号、动态/倒计时 GLB、Safari 预览和 preview-assets 测试。
### Git Commits
| Hash | Message |
|------|---------|
| `1c077a3` | (see git log) |
### Status
[OK] **Completed**
## Session 27: QGIS traffic signal editing and countdown stability
**Date**: 2026-08-07
**Task**: QGIS traffic signal editing and countdown stability
**Branch**: `main`
### Summary
Implemented editable traffic signal assemblies in QGIS with stable IDs, position/heading overrides, reimport ownership, source validation, and Blender/Cesium runtime derivation. Fixed long signal IDs overflowing Blender node names with short nodeKey values, and changed QGIS SVG symbols to direct heading_deg field rotation so QGIS rotation edits write back to the field. Verified real GeoPackage round-trip, focused Node tests, preview tests, and 59 Blender tests. Safari cache caused stale GLB symptoms and was resolved with a hard refresh.
### Git Commits
| Hash | Message |
|------|---------|
| `e153a1c` | (see git log) |
### Status
[OK] **Completed**

View File

@@ -0,0 +1,41 @@
# Workspace Index - que01
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 1
- **Last Active**: 2026-08-08
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~28 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 1 | 2026-08-08 | 修正 Cesium 巡航车道中心对齐 | `5658e73` | `main` |
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions

View File

@@ -0,0 +1,28 @@
# Journal - que01 (Part 1)
> AI development session journal
> Started: 2026-08-08
---
## Session 1: 修正 Cesium 巡航车道中心对齐
**Date**: 2026-08-08
**Task**: 修正 Cesium 巡航车道中心对齐
**Branch**: `main`
### Summary
巡航路线改用 osm2streets Driving lane polygon 中轴和 internal road 拓扑Blender 改用与 Cesium 一致的 WGS84 ECEF 到 ENU 投影修复随锚点距离增长的整体偏移并补齐连接器、预览、manifest、投影测试与项目规范。
### Git Commits
| Hash | Message |
|------|---------|
| `5658e73` | (see git log) |
### Status
[OK] **Completed**

View File

@@ -20,10 +20,14 @@
需要:
- macOS QGIS默认 `/Applications/QGIS.app`
- Blender:默认 `/Applications/Blender.app`
- QGIS 与 GDAL 工具链
- Blender
- Node.js / npm
macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender
`/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为
`/usr``blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。
首次使用:
```bash
@@ -109,7 +113,9 @@ outputs/<area-id>/_pipeline/stages/compress.manifest.json
manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要前段记录 OSM /
GeoJSON feature countsBlender 记录 `.blend` / renderCesium/压缩记录 GLB digest
preview 记录 GLB、metadata、车辆路线和 runtime 文件。`diagnose:area` 会读取这些
preview 记录 GLB、metadata、`lane_polygons.geojson``network.json``intersection_surface.geojson`
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,
路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些
manifest缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests``Warnings`
里标出来。
@@ -220,7 +226,7 @@ python3 -m http.server 8765
## 实验:车辆巡航
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`OSM 提供可行驶拓扑与转向语义,实际巡航坐标来自 osm2streets 的 `Driving` lane polygon 中轴;同向多车道按下一次 maneuver 选择兼容车道。路线中的 `laneSegments` 记录每个 polygon fragment 的 OSM way、方向、lane index、width、center offset 和来源。车道数据缺失、歧义、断裂或没有兼容转向车道时,候选路线会被跳过并写入顶层 `diagnostics`,不会回退到固定偏移或默认宽度。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。

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

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M 50 96 L 24 50 L 76 50 Z"
fill="#e12d37" stroke="#7d0f19" stroke-width="4" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -127,12 +127,12 @@ EXPORT_EMISSION_OVERRIDES = {
def cli_args():
values = {"blend": None, "glb": None, "metadata": None}
values = {"blend": None, "glb": None, "metadata": None, "dynamic_glb": None, "countdown_0_glb": None, "countdown_1_glb": None}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
if argv[i].startswith("--") and i + 1 < len(argv):
values[argv[i][2:]] = argv[i + 1]
values[argv[i][2:].replace("-", "_")] = argv[i + 1]
i += 2
else:
i += 1
@@ -609,6 +609,8 @@ def export(args):
material_map = {}
meshes = []
dynamic_meshes = []
countdown_meshes = {0: [], 1: []}
unwrapped = set()
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
@@ -617,7 +619,14 @@ def export(args):
continue
if obj.hide_viewport or obj.hide_render:
continue
meshes.append(obj)
if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection):
groups = {slot.material.name for slot in obj.material_slots if slot.material}
group = (0 if any("Countdown Group 0" in name for name in groups)
else 1 if any("Countdown Group 1" in name for name in groups)
else None)
(countdown_meshes[group] if group is not None else dynamic_meshes).append(obj)
else:
meshes.append(obj)
apply_mesh_modifiers(obj)
# Hundreds of grass tufts share four mesh datablocks; unwrapping and
# triangulating are properties of the mesh, so once per datablock.
@@ -642,6 +651,14 @@ def export(args):
slot.material = material_map[source.name]
export_glb(args["glb"], meshes)
if args.get("dynamic_glb"):
if not dynamic_meshes:
raise RuntimeError("Dynamic traffic signal collection is empty")
export_glb(args["dynamic_glb"], dynamic_meshes)
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group])
semantic_assets = semantic_asset_specs(args["glb"], meshes)
for asset in semantic_assets:
export_glb(asset["path"], asset["meshes"])
@@ -664,6 +681,19 @@ def export(args):
"type": "model",
"url": os.path.basename(args["glb"]),
"enabled": True,
}, {
"id": "traffic-dynamic",
"label": "Traffic signals dynamic",
"type": "model",
"url": os.path.basename(args["dynamic_glb"]) if args.get("dynamic_glb") and dynamic_meshes else "",
"enabled": True,
"category": "dynamic",
}, {
"id": "traffic-countdown-0", "label": "Traffic countdown group 0", "type": "model",
"url": os.path.basename(args["countdown_0_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 0,
}, {
"id": "traffic-countdown-1", "label": "Traffic countdown group 1", "type": "model",
"url": os.path.basename(args["countdown_1_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 1,
}] + [{
"id": asset["id"],
"label": asset["label"],

View File

@@ -59,6 +59,7 @@ from osmassets import grass as _grass # noqa: E402
from osmassets import roads as _roads # noqa: E402
from osmassets import scrub as _scrub # noqa: E402
from osmassets import tree as _tree # noqa: E402
from osmassets import traffic_signals as _traffic_signals # noqa: E402
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
@@ -594,7 +595,8 @@ def clear_scene():
def configure_scene():
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE_NEXT"
engines = scene.render.bl_rna.properties["engine"].enum_items.keys()
scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE"
scene.render.resolution_x = 1200
scene.render.resolution_y = 900
scene.render.resolution_percentage = 100
@@ -637,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"])
@@ -660,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"]))
@@ -689,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):
@@ -780,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:
@@ -882,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)
@@ -913,6 +953,8 @@ def build(args):
"scrub_bushes": counts["scrub_bush_count"],
"scrub_trees": counts["scrub_tree_count"],
"fountains": counts["fountain_count"],
"traffic_signals": counts["traffic_signal_count"],
"traffic_signal_dynamic_objects": dynamic_signal_objects,
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,
"trees": len(trees),

View File

@@ -152,6 +152,41 @@ MATERIALS = {
"cesium": {"tint": None,
"base_color": (0.11, 0.34, 0.075),
"emission": ((0.04, 0.11, 0.035), 0.02)}},
"traffic_signal_metal": {"kind": "solid", "name": "Traffic Signal Metal",
"color": (0.045, 0.065, 0.075), "roughness": 0.42,
"metallic": 0.62,
"cesium": {"base_color": (0.12, 0.16, 0.18),
"metallic": 0.42,
"emission": ((0.035, 0.05, 0.06), 0.03)}},
"traffic_signal_housing": {"kind": "solid", "name": "Traffic Signal Housing",
"color": (0.02, 0.03, 0.035), "roughness": 0.54,
"metallic": 0.12,
"cesium": {"base_color": (0.055, 0.075, 0.085),
"emission": ((0.018, 0.025, 0.03), 0.025)}},
# Static lenses are intentionally neutral and dark. The separate dynamic
# GLB is the sole source of phase colour, so inactive red/yellow/green
# glass cannot visually mask an otherwise working phase transition.
"traffic_signal_red": {"kind": "solid", "name": "Traffic Signal Red Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_yellow": {"kind": "solid", "name": "Traffic Signal Yellow Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_green": {"kind": "solid", "name": "Traffic Signal Green Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_active_red": {"kind": "solid", "name": "Traffic Signal Active Red",
"color": (0.93, 0.05, 0.035), "roughness": 0.25,
"cesium": {"base_color": (0.93, 0.05, 0.035),
"emission": ((0.93, 0.05, 0.035), 1.0)}},
"traffic_signal_active_yellow": {"kind": "solid", "name": "Traffic Signal Active Yellow",
"color": (0.98, 0.63, 0.03), "roughness": 0.25,
"cesium": {"base_color": (0.98, 0.63, 0.03),
"emission": ((0.98, 0.63, 0.03), 1.0)}},
"traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green",
"color": (0.04, 0.82, 0.22), "roughness": 0.25,
"cesium": {"base_color": (0.04, 0.82, 0.22),
"emission": ((0.04, 0.82, 0.22), 1.0)}},
}

View File

@@ -149,23 +149,55 @@ def parse_height(feature_tags, default):
class Projector:
"""Equirectangular projection about the centre of the OSM bounds.
"""WGS84 ECEF to local ENU projection about the OSM bounds centre.
Output is metres in a local ENU frame (X east, Y north), which is what both
the Blender scene and the Cesium GLB are authored in.
the Blender scene and the Cesium GLB are authored in. Cesium places the
GLB with eastNorthUpToFixedFrame, so using the same ellipsoid transform is
required to keep route coordinates aligned across the whole scene.
"""
WGS84_A = 6378137.0
WGS84_E2 = 6.6943799901413165e-3
def __init__(self, bounds):
self.bounds = bounds
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
self.m_per_lat = 111320.0
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
self._lon0_rad = math.radians(self.lon0)
self._lat0_rad = math.radians(self.lat0)
self._sin_lon0 = math.sin(self._lon0_rad)
self._cos_lon0 = math.cos(self._lon0_rad)
self._sin_lat0 = math.sin(self._lat0_rad)
self._cos_lat0 = math.cos(self._lat0_rad)
self._origin_ecef = self._ecef(self._lon0_rad, self._lat0_rad)
denominator = math.sqrt(1.0 - self.WGS84_E2 * self._sin_lat0 ** 2)
prime_vertical_radius = self.WGS84_A / denominator
meridional_radius = self.WGS84_A * (1.0 - self.WGS84_E2) / denominator ** 3
radians_per_degree = math.pi / 180.0
self.m_per_lon = prime_vertical_radius * self._cos_lat0 * radians_per_degree
self.m_per_lat = meridional_radius * radians_per_degree
def xy(self, lon_lat):
lon, lat = lon_lat
return ((lon - self.lon0) * self.m_per_lon,
(lat - self.lat0) * self.m_per_lat)
x, y, z = self._ecef(math.radians(lon), math.radians(lat))
dx = x - self._origin_ecef[0]
dy = y - self._origin_ecef[1]
dz = z - self._origin_ecef[2]
east = -self._sin_lon0 * dx + self._cos_lon0 * dy
north = (-self._sin_lat0 * self._cos_lon0 * dx
- self._sin_lat0 * self._sin_lon0 * dy
+ self._cos_lat0 * dz)
return east, north
def _ecef(self, lon_rad, lat_rad):
sin_lat = math.sin(lat_rad)
cos_lat = math.cos(lat_rad)
radius = self.WGS84_A / math.sqrt(1.0 - self.WGS84_E2 * sin_lat ** 2)
return (radius * cos_lat * math.cos(lon_rad),
radius * cos_lat * math.sin(lon_rad),
radius * (1.0 - self.WGS84_E2) * sin_lat)
def inside(self, lon_lat, pad=0.00035):
lon, lat = lon_lat

View File

@@ -0,0 +1,370 @@
"""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
node_key = signal.get("nodeKey") or signal["id"]
for state in ("red", "yellow", "green"):
batch = MeshBatch("TrafficSignalDynamic_%s_%s" % (node_key, 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" % (node_key, 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

@@ -238,14 +238,23 @@ class ProjectorTest(unittest.TestCase):
self.assertGreater(east, 0.0)
self.assertGreater(north, 0.0)
def test_longitude_metres_shrink_with_latitude(self):
self.assertAlmostEqual(
self.projector.m_per_lon,
111320.0 * math.cos(math.radians(30.005)),
places=6,
)
def test_wgs84_local_scale_matches_ellipsoid(self):
latitude = math.radians(30.005)
denominator = math.sqrt(1.0 - Projector.WGS84_E2 * math.sin(latitude) ** 2)
expected_lon = (Projector.WGS84_A / denominator
* math.cos(latitude) * math.pi / 180.0)
expected_lat = (Projector.WGS84_A * (1.0 - Projector.WGS84_E2)
/ denominator ** 3 * math.pi / 180.0)
self.assertAlmostEqual(self.projector.m_per_lon, expected_lon, places=6)
self.assertAlmostEqual(self.projector.m_per_lat, expected_lat, places=6)
self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat)
def test_projection_matches_local_wgs84_scale(self):
east, _ = self.projector.xy((114.006, 30.005))
_, north = self.projector.xy((114.005, 30.006))
self.assertAlmostEqual(east, self.projector.m_per_lon * 0.001, places=4)
self.assertAlmostEqual(north, self.projector.m_per_lat * 0.001, places=4)
def test_inside_honours_the_pad(self):
self.assertTrue(self.projector.inside((114.005, 30.005)))
# Default pad is 0.00035 degrees, so just outside the box still counts.

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

@@ -209,7 +209,8 @@ def render_preview(objects, path):
light.location = location
light.rotation_euler = (0, 0, 0)
light.rotation_euler = (target - light.location).to_track_quat("-Z", "Y").to_euler()
scene.render.engine = "BLENDER_EEVEE_NEXT"
engines = scene.render.bl_rna.properties["engine"].enum_items.keys()
scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE"
scene.render.resolution_x = PREVIEW_SIZE
scene.render.resolution_y = PREVIEW_SIZE
scene.render.resolution_percentage = 100

2
package-lock.json generated
View File

@@ -13,7 +13,7 @@
},
"node_modules/osm2streets-js-node": {
"version": "0.1.4",
"resolved": "http://172.16.1.86:4873/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
"license": "Apache-2.0"
}

View File

@@ -16,6 +16,7 @@
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:compress-glb": "node scripts/test-compress-glb.js",
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
"test:traffic-signals": "node scripts/test-traffic-signals.js",
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {

View File

@@ -4,6 +4,7 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
const {
SCENE_LAYERS,
SCENE_FILE,
@@ -13,6 +14,7 @@ const {
const { digest: glbDigest } = require("./glb-digest");
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { readTrafficSignals } = require("./lib/traffic-signals");
const {
cesiumPreviewHtml,
previewSummary,
@@ -151,6 +153,7 @@ function writeDerivedConfig(area) {
gpkg: area.outputs.gpkg,
project: area.outputs.qgisProject,
preview: area.outputs.qgisPreview,
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
arrowScale: area.qgis.arrowScale,
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
@@ -180,6 +183,7 @@ function buildIntermediates(area) {
"--config",
derivedConfigPath,
], "intermediates");
writeTrafficSignals(area);
fs.rmSync(stageManifestPath(area, "reimport"), { force: true });
const finished = Date.now();
writeStageManifest(area, {
@@ -197,12 +201,15 @@ function buildIntermediates(area) {
derivedConfig: fileRecord(derivedConfigPath),
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
gpkg: fileRecord(area.outputs.gpkg),
qgisProject: fileRecord(area.outputs.qgisProject),
qgisPreview: optionalFileRecord(area.outputs.qgisPreview),
},
summary: {
geojson: geojsonFeatureCounts(area),
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
},
warnings: [],
});
@@ -219,6 +226,7 @@ function reimportGpkg(area) {
"--config",
derivedConfigPath,
], "reimport");
writeTrafficSignals(area);
fs.rmSync(stageManifestPath(area, "intermediates"), { force: true });
const finished = Date.now();
writeStageManifest(area, {
@@ -236,9 +244,12 @@ function reimportGpkg(area) {
outputs: {
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
},
summary: {
geojson: geojsonFeatureCounts(area),
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
},
warnings: [],
});
@@ -247,6 +258,11 @@ 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.trafficSignalAssemblies, "Editable traffic signal assemblies");
// Blender consumes the editable assembly layer; OSM only initializes it in
// intermediates, so QGIS edits remain authoritative across later stages.
writeTrafficSignals(area);
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 });
@@ -288,6 +304,8 @@ function buildBlenderScene(area) {
osm: fileRecord(area.input),
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
},
outputs: {
blend: fileRecord(area.outputs.blend),
@@ -314,6 +332,7 @@ function exportCesium(area) {
const startedAt = new Date(started).toISOString();
runCommand(blenderExecutable(area), [
"--background",
"--factory-startup",
"--python",
path.join(repoRoot, "blender", "export_cesium.py"),
"--",
@@ -321,9 +340,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();
@@ -341,6 +369,7 @@ function exportCesium(area) {
outputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
semanticAssets,
},
summary: {
@@ -440,7 +469,7 @@ function compressCesiumGlb(area) {
}
function blenderExecutable(area) {
return path.join(area.blenderApp, "Contents", "MacOS", "Blender");
return resolveBlenderExecutable(area.blenderApp);
}
function ensureFile(file, label) {
@@ -463,18 +492,27 @@ 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 lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const network = path.join(area.outputs.geojsonDir, "network.json");
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
ensureFile(lanePolygons, "Driving lane polygons");
ensureFile(network, "osm2streets network");
ensureFile(intersectionSurface, "Intersection surfaces");
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
const htmlPath = area.outputs.cesiumPreview;
const started = Date.now();
const startedAt = new Date(started).toISOString();
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
writeVehicleRoute(area);
writeVehicleRoute(area, vehicleRoute);
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));
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, {
@@ -489,6 +527,9 @@ function writeCesiumPreview(area) {
osm: fileRecord(area.input),
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
},
@@ -496,14 +537,24 @@ function writeCesiumPreview(area) {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel),
trafficSignals: fileRecord(area.outputs.trafficSignals),
},
summary: previewSummary(area),
warnings: [],
});
}
function writeVehicleRoute(area) {
const route = buildPreviewVehicleRoute(area.input);
function writeTrafficSignals(area) {
const signals = readTrafficSignals(area.outputs.trafficSignalAssemblies, area.input);
fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`);
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
}
function previewRelativePath(fromDir, target) {
return path.relative(fromDir, target).split(path.sep).join("/");
}
function writeVehicleRoute(area, route) {
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`);
console.log(`Vehicle route: ${area.outputs.vehicleRoute}`);

View File

@@ -5,9 +5,12 @@ const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const { JsStreetNetwork } = require("osm2streets-js-node");
const { qgisPaths } = require("./lib/tool-paths");
const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows");
const { readTrafficSignalFeatures } = require("./lib/traffic-signals");
const {
SCENE_LAYERS,
AUXILIARY_EDIT_LAYERS,
SCENE_FILE,
SCENE_STYLE_FILE,
layerFile,
@@ -21,9 +24,9 @@ const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "default.json"));
const config = loadConfig(configPath, args);
const qgisApp = config.qgisApp;
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
const qgisPython = path.join(qgisMacOS, "python3.12");
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr");
const qgis = qgisPaths(qgisApp);
const qgisPython = qgis.python;
const ogr2ogr = qgis.ogr2ogr;
const normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py");
const inputPath = path.resolve(config.input);
const outDir = path.resolve(config.outDir);
@@ -38,6 +41,11 @@ const clipPad = Number(config.clipPad);
const canvasPad = Number(config.canvasPad);
const previewPad = Number(config.previewPad);
const layerPrefix = config.layerPrefix || "osm2streets";
const trafficSignalLayer = AUXILIARY_EDIT_LAYERS.find((layer) => layer.id === "traffic_signal_assemblies");
if (!trafficSignalLayer) throw new Error("Missing traffic_signal_assemblies auxiliary layer definition");
const trafficSignalAssembliesPath = path.resolve(
config.trafficSignalAssemblies || path.join(outDir, trafficSignalLayer.file),
);
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
throw new Error(`Invalid arrowScale: ${config.arrowScale}`);
@@ -115,6 +123,11 @@ fs.writeFileSync(
for (const layer of SCENE_LAYERS) {
writeJson(path.join(outDir, layerFile(layer)), split[layer.splitKey]);
}
writeJson(trafficSignalAssembliesPath, readTrafficSignalFeatures(
path.join(outDir, "vehicle_stop_lines.geojson"),
path.join(outDir, "intersection_surface.geojson"),
inputPath,
));
if (arrowMergeTriangles) {
normalizeLaneArrows(path.join(outDir, "lane_arrows_webscale.geojson"), arrowOutlineSimplifyMeters);
split.laneArrows = JSON.parse(fs.readFileSync(path.join(outDir, "lane_arrows_webscale.geojson"), "utf8"));
@@ -128,11 +141,12 @@ fs.writeFileSync(
if (fs.existsSync(gpkgPath)) {
fs.unlinkSync(gpkgPath);
}
const ogrEnv = qgisEnv();
const ogrEnv = qgis.env;
// First layer creates the GeoPackage; the rest append into it.
SCENE_LAYERS.forEach((layer, index) => {
importLayer(gpkgPath, path.join(outDir, layerFile(layer)), layer.id, index > 0, ogrEnv);
});
importLayer(gpkgPath, trafficSignalAssembliesPath, trafficSignalLayer.id, true, ogrEnv);
const qgisScript = path.join(outDir, "_create_qgis_project.py");
const previewFeature = split.crosswalks.features[0] || split.laneArrows.features[0] || split.roadSurface.features[0];
@@ -141,30 +155,23 @@ const defaultPreviewExtent = extentString(expandBounds(
previewPad,
));
fs.writeFileSync(qgisScript, makeQgisScript({
qgisMacOS,
qgisPrefix: qgis.prefix,
gpkgPath,
projectPath,
previewPath,
layerPrefix,
canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)),
previewExtent: config.previewExtent || defaultPreviewExtent,
trafficSignalSymbolPath: path.join(repoRoot, "assets", "qgis", "traffic-signal-direction.svg"),
}));
execFileSync(qgisPython, [qgisScript], {
stdio: "inherit",
env: {
...process.env,
...qgisEnv(),
...qgis.env,
QT_QPA_PLATFORM: "offscreen",
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
PYTHONPATH: [
path.join(qgisApp, "Contents", "Resources", "python"),
path.join(qgisApp, "Contents", "Resources", "python3.11", "site-packages"),
].join(":"),
DYLD_LIBRARY_PATH: [
qgisMacOS,
path.join(qgisApp, "Contents", "Frameworks"),
].join(":"),
...qgis.pythonEnv,
},
});
fixCanvas(projectPath, config.canvasExtent || extentString(expandBounds(bbox, canvasPad)));
@@ -252,13 +259,6 @@ function deepMerge(base, overrides) {
return out;
}
function qgisEnv() {
return {
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
};
}
function getOsmBounds(xmlText) {
// OSM exports may contain distant relation members (for example subway
// nodes) that are not part of the requested map extent. Prefer the explicit
@@ -1418,13 +1418,9 @@ function normalizeLaneArrows(geojsonPath, outlineSimplifyMeters) {
stdio: "inherit",
env: {
...process.env,
...qgisEnv(),
...qgis.env,
QT_QPA_PLATFORM: "offscreen",
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
PYTHONPATH: [
path.join(qgisApp, "Contents", "Resources", "python"),
path.join(qgisApp, "Contents", "Resources", "python", "plugins"),
].join(path.delimiter),
...qgis.pythonEnv,
},
});
}
@@ -1455,16 +1451,25 @@ from qgis.PyQt.QtGui import QColor, QImage, QPainter
from qgis.core import (
QgsApplication,
QgsCoordinateReferenceSystem,
QgsEditorWidgetSetup,
QgsFieldConstraints,
QgsFillSymbol,
QgsMarkerSymbol,
QgsMapRendererCustomPainterJob,
QgsMapSettings,
QgsProject,
QgsPalLayerSettings,
QgsProperty,
QgsRectangle,
QgsSingleSymbolRenderer,
QgsSymbolLayer,
QgsSvgMarkerSymbolLayer,
QgsVectorLayerSimpleLabeling,
QgsVectorLayer,
)
QGIS_PREFIX = ${JSON.stringify(options.qgisMacOS)}
QGIS_PREFIX = ${JSON.stringify(options.qgisPrefix)}
TRAFFIC_SIGNAL_SYMBOL = ${JSON.stringify(options.trafficSignalSymbolPath)}
GPKG = ${JSON.stringify(options.gpkgPath)}
PROJECT_PATH = ${JSON.stringify(options.projectPath)}
PREVIEW_PATH = ${JSON.stringify(options.previewPath)}
@@ -1472,6 +1477,16 @@ PREVIEW_EXTENT = [${options.previewExtent.split(",").map(Number).join(", ")}]
LAYER_PREFIX = ${JSON.stringify(options.layerPrefix || "osm2streets")}
LAYER_SPECS = ${JSON.stringify(qgisLayerSpecs(), null, 4)}
try:
IMAGE_FORMAT = QImage.Format.Format_ARGB32_Premultiplied
except AttributeError:
IMAGE_FORMAT = QImage.Format_ARGB32_Premultiplied
try:
NOT_NULL_CONSTRAINT = QgsFieldConstraints.Constraint.ConstraintNotNull
except AttributeError:
NOT_NULL_CONSTRAINT = QgsFieldConstraints.ConstraintNotNull
def fill_symbol(color, outline="0,0,0,0", outline_width="0"):
return QgsFillSymbol.createSimple({
"color": color,
@@ -1488,6 +1503,38 @@ def make_layer(layer_name, title, color, outline="0,0,0,0", outline_width="0"):
layer.setRenderer(QgsSingleSymbolRenderer(fill_symbol(color, outline, outline_width)))
return layer
def make_signal_layer():
layer = QgsVectorLayer(f"{GPKG}|layername=traffic_signal_assemblies", f"{LAYER_PREFIX} traffic signal assemblies", "ogr")
if not layer.isValid():
raise RuntimeError("Invalid traffic signal assemblies layer")
symbol = QgsMarkerSymbol()
svg_layer = QgsSvgMarkerSymbolLayer(TRAFFIC_SIGNAL_SYMBOL, 9)
svg_layer.setDataDefinedProperty(
QgsSymbolLayer.Property.Angle,
QgsProperty.fromField("heading_deg"),
)
symbol.changeSymbolLayer(0, svg_layer)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
labels = QgsPalLayerSettings()
labels.fieldName = "if(trim(display_id) = '', signal_uid, display_id)"
labels.isExpression = True
layer.setLabeling(QgsVectorLayerSimpleLabeling(labels))
layer.setLabelsEnabled(True)
for field_name in ("signal_uid", "control_id", "approach_id", "source_way_id", "stop_lon", "stop_lat"):
index = layer.fields().indexOf(field_name)
if index >= 0:
layer.setFieldConstraint(index, NOT_NULL_CONSTRAINT)
form = layer.editFormConfig()
form.setReadOnly(index, True)
layer.setEditFormConfig(form)
enabled_index = layer.fields().indexOf("enabled")
if enabled_index >= 0:
layer.setEditorWidgetSetup(enabled_index, QgsEditorWidgetSetup("CheckBox", {"CheckedState": "1", "UncheckedState": "0"}))
phase_index = layer.fields().indexOf("phase_group")
if phase_index >= 0:
layer.setEditorWidgetSetup(phase_index, QgsEditorWidgetSetup("ValueMap", {"map": [{"Phase 0": 0}, {"Phase 1": 1}]}))
return layer
QgsApplication.setPrefixPath(QGIS_PREFIX, True)
app = QgsApplication([], False)
app.initQgis()
@@ -1508,12 +1555,16 @@ layers = {
)
for spec in LAYER_SPECS
}
signal_layer = make_signal_layer()
layers["traffic_signal_assemblies"] = signal_layer
draw_order = [spec["id"] for spec in LAYER_SPECS]
for key in draw_order:
project.addMapLayer(layers[key], False)
project.addMapLayer(signal_layer, False)
root = project.layerTreeRoot()
for key in draw_order:
root.insertLayer(0, layers[key])
root.insertLayer(0, signal_layer)
if not project.write(PROJECT_PATH):
raise RuntimeError(f"Failed to write {PROJECT_PATH}")
@@ -1523,7 +1574,7 @@ settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
settings.setExtent(QgsRectangle(*PREVIEW_EXTENT))
settings.setOutputSize(QSize(1600, 1100))
settings.setBackgroundColor(QColor(245, 245, 240))
image = QImage(settings.outputSize(), QImage.Format_ARGB32_Premultiplied)
image = QImage(settings.outputSize(), IMAGE_FORMAT)
image.fill(settings.backgroundColor().rgba())
painter = QPainter(image)
job = QgsMapRendererCustomPainterJob(settings, painter)

View File

@@ -28,15 +28,21 @@ function normalizeAreaConfig(raw, options = {}) {
const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const outputs = {
areaDir,
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
geojsonDir,
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
trafficSignalsDynamicGlb: path.resolve(
outputOverrides.trafficSignalsDynamicGlb || path.join(areaDir, `${fileStem}-traffic-signals-dynamic.glb`),
),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
@@ -52,6 +58,12 @@ 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`)),
trafficSignalAssemblies: path.resolve(
outputOverrides.trafficSignalAssemblies || path.join(geojsonDir, "traffic_signal_assemblies.geojson"),
),
// Runtime poses are derived from the editable assembly layer and shared by
// Blender and the browser preview.
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(geojsonDir, "traffic_signals.json")),
pipelineDir,
stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")),
};
@@ -60,8 +72,8 @@ function normalizeAreaConfig(raw, options = {}) {
id,
input,
outputRoot,
qgisApp: raw.qgisApp || "/Applications/QGIS.app",
blenderApp: raw.blenderApp || "/Applications/Blender.app",
qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"),
blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"),
stages: {
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true,
blender: raw.stages?.blender ?? true,

View File

@@ -327,6 +327,8 @@ function artifactStatus(area) {
["GeoPackage", area.outputs.gpkg, true, "file"],
["QGIS project", area.outputs.qgisProject, true, "file"],
["QGIS preview", area.outputs.qgisPreview, true, "file"],
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
["Traffic signal runtime", area.outputs.trafficSignals, true, "file"],
["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"],
["Cesium GLB", area.outputs.glb, true, "file"],
@@ -400,6 +402,8 @@ function stageManifestStatus(area, configPath = null) {
derivedConfig,
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
gpkg: area.outputs.gpkg,
qgisProject: area.outputs.qgisProject,
qgisPreview: optionalExpectedFile(area.outputs.qgisPreview),
@@ -416,6 +420,8 @@ function stageManifestStatus(area, configPath = null) {
outputs: {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
},
},
{
@@ -426,6 +432,8 @@ function stageManifestStatus(area, configPath = null) {
osm: area.input,
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
},
outputs: {
blend: area.outputs.blend,
@@ -451,6 +459,9 @@ function stageManifestStatus(area, configPath = null) {
osm: area.input,
glb: area.outputs.glb,
metadata: area.outputs.metadata,
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
network: path.join(area.outputs.geojsonDir, "network.json"),
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
},

View File

@@ -13,7 +13,7 @@ function writeCesiumPreviewSupportFiles(outDir) {
}
}
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = []) {
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) {
const previewConfig = {
areaId,
glbName,
@@ -21,6 +21,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
routeName,
vehicleModelName,
vehicleModelNames,
trafficSignalsName,
};
return `<!doctype html>
<html lang="zh-CN">
@@ -49,10 +50,12 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<button type="button" data-view-mode="inspect" aria-pressed="false">Inspect</button>
</span>
<label><input id="toggleScene" type="checkbox" checked> Scene</label>
<label><input id="toggleBuildingGhost" type="checkbox"> Building ghost</label>
<span id="assetToggles" class="control-subgroup"></span>
<span id="semanticToggles" class="control-subgroup hidden"></span>
<label><input id="toggleRoutes" type="checkbox" checked> Routes</label>
<label><input id="toggleVehicles" type="checkbox" checked> Vehicles</label>
<label id="signalsControl"><input id="toggleSignals" type="checkbox" checked> Signals</label>
<label><input id="toggleFps" type="checkbox"> FPS</label>
<label><input id="toggleDiagnostics" type="checkbox" checked> Info</label>
</div>
@@ -106,6 +109,7 @@ function previewSummary(area) {
metadataName: path.basename(area.outputs.metadata),
routeName: path.basename(area.outputs.vehicleRoute),
vehicleModelName: path.basename(area.outputs.vehicleModel),
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
};
}

View File

@@ -8,11 +8,19 @@
const toggleCruise = document.getElementById("toggleCruise");
const toggleFollow = document.getElementById("toggleFollow");
const toggleScene = document.getElementById("toggleScene");
const toggleBuildingGhost = document.getElementById("toggleBuildingGhost");
const toggleRoutes = document.getElementById("toggleRoutes");
const toggleVehicles = document.getElementById("toggleVehicles");
const toggleSignals = document.getElementById("toggleSignals");
const signalsControl = document.getElementById("signalsControl");
const toggleFps = document.getElementById("toggleFps");
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
const assetToggles = document.getElementById("assetToggles");
// The exported road surface sits at the 0.35m scene anchor plus 0.03m.
// Vehicle models have their wheels at local Y=0, so keep them just clear
// of the asphalt instead of using the old visibly floating 1.15m height.
const VEHICLE_HEIGHT_METERS = 0.40;
const ROUTE_LINE_HEIGHT_METERS = 0.42;
const semanticToggles = document.getElementById("semanticToggles");
const vehicleSelect = document.getElementById("vehicleSelect");
const speedControl = document.getElementById("speedControl");
@@ -33,17 +41,20 @@
setLoadingMessage("Loading scene", config.areaId || "");
const metadata = await fetchJson(config.metadataName);
const routeData = await fetchOptionalJson(config.routeName);
const signalData = await fetchOptionalJson(config.trafficSignalsName);
const placement = scenePlacement(metadata);
const viewer = createViewer();
setLoadingMessage("Loading model", config.glbName || "");
const assets = await loadSceneAssets(viewer, metadata, placement);
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName);
const trafficStart = Cesium.JulianDate.now();
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
buildAssetToggles(assets);
buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement);
startDiagnostics(viewer, metadata, assets, cruise, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
setStatus(baseStatus);
@@ -52,11 +63,13 @@
document.body.classList.add("scene-ready");
// Handle for the browser console and for headless checks: everything else
// in here is closed over by the IIFE and unreachable from outside.
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras };
}
async function fetchJson(url) {
const response = await fetch(url);
// Generated preview JSON keeps a stable filename; bypass browser caches so
// route regeneration is visible immediately during inspection.
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error("Could not load " + url + ": " + response.status);
}
@@ -272,13 +285,65 @@
}
}
function bindRuntimeControls(viewer, assets, cruise, cameras, placement) {
function bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals) {
const hasVehicles = cruise.vehicles.length > 0;
const hasSemanticAssets = semanticAssets(assets).length > 0;
const sceneLabel = toggleScene.closest("label");
let viewMode = "scene";
let buildingGhostActive = false;
async function setBuildingGhost(enabled) {
const roads = assets.find((asset) => asset.id === "roads");
const buildings = assets.find((asset) => asset.id === "buildings");
const props = assets.find((asset) => asset.id === "vegetation");
const main = assets.find((asset) => asset.id === "main");
if (!roads || !buildings || !props || !main) {
toggleBuildingGhost.checked = false;
toggleBuildingGhost.disabled = true;
return;
}
if (enabled) {
const loaded = await Promise.all([
loadAsset(viewer, roads, placement),
loadAsset(viewer, buildings, placement),
loadAsset(viewer, props, placement),
]);
if (!loaded[0] || !loaded[1] || !loaded[2]) {
toggleBuildingGhost.checked = false;
setStatus("Building transparency unavailable");
return;
}
main.model.show = false;
roads.model.show = true;
buildings.model.show = true;
// The semantic vegetation asset also owns the static traffic-signal
// poles/housings from the 05_Props collection.
props.model.show = true;
buildings.model.color = Cesium.Color.WHITE.withAlpha(0.22);
buildings.model.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
buildings.model.colorBlendAmount = 1.0;
for (const asset of liveAssets(assets)) {
if (asset.id !== "main") asset.model.show = asset.category === "dynamic" || asset.category === "countdown"
? toggleSignals.checked : asset.model.show;
}
trafficSignals.show = toggleSignals.checked;
buildingGhostActive = true;
setStatus("Buildings transparent");
} else {
for (const asset of semanticAssets(assets)) {
if (asset.model) asset.model.show = false;
}
main.model.show = toggleScene.checked;
for (const asset of liveAssets(assets)) {
if (asset.id !== "main") asset.model.show = toggleSignals.checked;
}
buildingGhostActive = false;
setStatus("Buildings opaque");
}
}
toggleScene.addEventListener("change", () => {
if (buildingGhostActive) return;
for (const asset of liveAssets(assets)) {
asset.model.show = toggleScene.checked;
if (asset.toggle) asset.toggle.checked = toggleScene.checked;
@@ -287,17 +352,33 @@
setStatus(toggleScene.checked ? "Scene visible" : "Scene hidden");
});
toggleRoutes.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.routeEntity.show = toggleRoutes.checked;
syncSelectedRouteVisibility(cruise);
});
toggleVehicles.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
});
if (!trafficSignals.count) {
signalsControl.classList.add("hidden");
} else {
toggleSignals.addEventListener("change", () => {
trafficSignals.show = toggleSignals.checked;
setStatus(toggleSignals.checked ? "Signals visible" : "Signals hidden");
});
}
toggleFps.addEventListener("change", () => {
viewer.scene.debugShowFramesPerSecond = toggleFps.checked;
});
toggleDiagnostics.addEventListener("change", () => {
diagnosticsEl.classList.toggle("hidden", !toggleDiagnostics.checked);
});
toggleBuildingGhost.addEventListener("change", () => {
void setBuildingGhost(toggleBuildingGhost.checked);
});
if (!semanticAssets(assets).some((asset) => asset.id === "roads") ||
!semanticAssets(assets).some((asset) => asset.id === "buildings") ||
!semanticAssets(assets).some((asset) => asset.id === "vegetation")) {
toggleBuildingGhost.disabled = true;
}
async function setViewMode(nextMode) {
if (nextMode === viewMode) return;
@@ -311,6 +392,10 @@
if (sceneLabel) sceneLabel.classList.toggle("hidden", inspecting);
if (!inspecting) {
if (buildingGhostActive) {
toggleBuildingGhost.checked = false;
await setBuildingGhost(false);
}
for (const asset of semanticAssets(assets)) {
if (asset.model) asset.model.show = false;
}
@@ -400,6 +485,7 @@
});
vehicleSelect.addEventListener("change", () => {
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
syncSelectedRouteVisibility(cruise);
setStatus(selectedVehicle(cruise).label);
});
@@ -422,12 +508,12 @@
return stopFollow;
}
function addVehicleCruises(viewer, routeData, vehicleModelNames, fallbackVehicleModelName) {
function addVehicleCruises(viewer, routeData, signalData, trafficStart, vehicleModelNames, fallbackVehicleModelName) {
const segments = ((routeData && (routeData.routes || routeData.segments)) || [])
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
.slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = Cesium.JulianDate.now();
const start = trafficStart;
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
@@ -436,7 +522,7 @@
viewer.clock.shouldAnimate = segments.length > 0;
const vehicles = segments.map((segment, index) => {
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed,
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
const option = document.createElement("option");
option.value = String(index);
@@ -444,11 +530,157 @@
vehicleSelect.appendChild(option);
return vehicle;
});
return {
const cruise = {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
};
syncSelectedRouteVisibility(cruise);
return cruise;
}
function syncSelectedRouteVisibility(cruise) {
for (let index = 0; index < cruise.vehicles.length; index += 1) {
cruise.vehicles[index].routeEntity.show = toggleRoutes.checked && index === cruise.state.selectedIndex;
}
}
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 countdownNodes = new WeakMap();
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 countdownNode = (model, name) => {
let modelNodes = countdownNodes.get(model);
if (!modelNodes) {
modelNodes = new Map();
countdownNodes.set(model, modelNodes);
}
if (modelNodes.has(name)) return modelNodes.get(name);
let value = null;
try { value = model.getNode(name); } catch (error) { /* model node table is still loading */ }
if (value) modelNodes.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 nodeKey = signal.nodeKey || signal.id;
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_${nodeKey}_${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_${nodeKey}_countdown_${String(value).padStart(2, "0")}`;
let countdown = null;
countdown = countdownNode(countdownModel, name);
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();
// Countdown GLBs may expose their node table a few frames after the
// model object exists. Re-apply the initial state once both models are
// ready so every hidden digit is explicitly hidden before the first
// user-visible frame.
for (const model of countdownModels.values()) {
if (model.readyPromise) model.readyPromise.then(() => update(0)).catch(() => {});
}
return {
entities, count: signals.length, dynamic, state, timer,
set show(value) {
dynamic.model.show = value;
for (const model of countdownModels.values()) model.show = value;
for (const entity of entities) entity.show = value;
}
};
}
return { entities: [], count: 0, set show(value) {} };
}
function signalColor(group, state, time, start) {
const active = signalPhase(group, time, start).active;
const color = signalBaseColor(state);
return state === active ? color : Cesium.Color.multiplyByScalar(color, 0.35, new Cesium.Color());
}
function signalActiveColor(group, time, start) {
return signalBaseColor(signalPhase(group, time, start).active);
}
function signalBaseColor(state) {
return Cesium.Color.fromCssColorString({ red: "#ee3f39", yellow: "#f7bf37", green: "#43cf71" }[state]);
}
function signalPhase(group, time, start) {
return signalPhaseAtElapsed(group, Cesium.JulianDate.secondsDifference(time, start));
}
function signalPhaseAtElapsed(group, elapsed) {
const second = ((elapsed % 20) + 20) % 20;
if (group === 0) {
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };
return { active: "red", remaining: Math.ceil(20 - second) };
}
if (second < 10) return { active: "red", remaining: Math.ceil(10 - second) };
if (second < 18) return { active: "green", remaining: Math.ceil(18 - second) };
return { active: "yellow", remaining: Math.ceil(20 - second) };
}
function selectedVehicleModelName(modelNames, fallbackModelName) {
@@ -459,14 +691,13 @@
return usable[Math.floor(Math.random() * usable.length)] || "";
}
function addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName) {
const route = prepareRoute(segment);
const positions = new Cesium.CallbackProperty((time, result) => {
return routePosition(route, start, time, speed, result);
}, false);
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed);
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
flat.push(coord[0], coord[1], 1.05);
flat.push(coord[0], coord[1], ROUTE_LINE_HEIGHT_METERS);
}
const routeColor = [
Cesium.Color.CYAN,
@@ -488,7 +719,7 @@
const vehicle = viewer.entities.add({
name: "Cruise vehicle " + (index + 1),
position: positions,
orientation: routeOrientation(route, start, speed, 0.0),
orientation: routeOrientationFromState(route, trafficMotion.state, speed, 0.0),
model: {
uri: vehicleModelName,
// Keep the library's world scale visible. A minimum screen size made
@@ -525,7 +756,7 @@
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
}
function prepareRoute(segment) {
function prepareRoute(segment, signalData) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
@@ -533,13 +764,63 @@
return {
coordinates: segment.coordinates,
distances,
length: Math.max(1.0, distances[distances.length - 1])
length: Math.max(1.0, distances[distances.length - 1]),
stops: routeStops(segment.coordinates, distances, signalData),
};
}
function routeStops(coordinates, distances, signalData) {
const found = new Map();
for (const signal of signalData?.signals || []) {
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) continue;
let best = { index: -1, distance: Infinity };
for (let index = 0; index < coordinates.length; index += 1) {
const distance = distanceMeters(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
if (distance < best.distance) best = { index, distance };
}
if (best.index >= 0 && best.distance <= 7) {
const routeDistance = distances[best.index];
const existing = found.get(Math.round(routeDistance));
if (!existing || best.distance < existing.matchDistance) found.set(Math.round(routeDistance), { distance: routeDistance, matchDistance: best.distance, signal });
}
}
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficAwarePositions(viewer, route, start, speed) {
const state = { distance: 0, lastTime: start.clone() };
viewer.clock.onTick.addEventListener((clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime);
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (elapsed <= 0) return;
const next = nextRouteStop(route, state.distance);
const advance = elapsed * speed;
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {
const untilStop = (next.distance - state.distance + route.length) % route.length;
if (untilStop <= advance + 1.7) {
state.distance = (next.distance - 1.7 + route.length) % route.length;
return;
}
}
state.distance = (state.distance + advance) % route.length;
});
return {
state,
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
};
}
function nextRouteStop(route, distance) {
return route.stops.find((stop) => stop.distance > distance + 0.05) || route.stops[0] || null;
}
function routePosition(route, start, time, speed, result) {
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
const distance = (seconds * speed) % route.length;
return routePositionAtDistance(route, distance, result);
}
function routePositionAtDistance(route, distance, result) {
let index = 1;
while (index < route.distances.length - 1 && route.distances[index] < distance) {
index += 1;
@@ -551,7 +832,7 @@
const b = route.coordinates[index];
const lon = a[0] + (b[0] - a[0]) * t;
const lat = a[1] + (b[1] - a[1]) * t;
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
return Cesium.Cartesian3.fromDegrees(lon, lat, VEHICLE_HEIGHT_METERS, Cesium.Ellipsoid.WGS84, result);
}
function routeOrientation(route, start, speed, yawDegrees) {
@@ -600,6 +881,43 @@
}, false);
}
function routeOrientationFromState(route, state, speed, yawDegrees) {
const correction = Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.UNIT_Z,
Cesium.Math.toRadians(yawDegrees)
);
const current = new Cesium.Cartesian3();
const ahead = new Cesium.Cartesian3();
const direction = new Cesium.Cartesian3();
const up = new Cesium.Cartesian3();
const east = new Cesium.Cartesian3();
const north = new Cesium.Cartesian3();
const hpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
const base = new Cesium.Quaternion();
return new Cesium.CallbackProperty((time, result) => {
routePositionAtDistance(route, state.distance, current);
// Sample a small distance ahead, rather than using velocity. This keeps
// the car aligned while stopped and preserves the model's route heading.
const lookAhead = Math.max(0.8, speed * 0.8);
routePositionAtDistance(route, (state.distance + lookAhead) % route.length, ahead);
Cesium.Cartesian3.subtract(ahead, current, direction);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) return result;
Cesium.Cartesian3.normalize(direction, direction);
Cesium.Cartesian3.normalize(current, up);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
} else {
Cesium.Cartesian3.normalize(east, east);
}
Cesium.Cartesian3.cross(up, east, north);
Cesium.Cartesian3.normalize(north, north);
hpr.heading = Math.atan2(Cesium.Cartesian3.dot(direction, east), Cesium.Cartesian3.dot(direction, north));
Cesium.Transforms.headingPitchRollQuaternion(current, hpr, undefined, undefined, base);
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
}, false);
}
function createChaseFollow(viewer, positionsProvider) {
const scratchPosition = new Cesium.Cartesian3();
const scratchPrevious = new Cesium.Cartesian3();
@@ -731,7 +1049,7 @@
// Camera-dependent readouts have to track the camera, so refresh off the
// render loop rather than a fixed timer, throttled to stay off the hot path.
function startDiagnostics(viewer, metadata, assets, cruise, placement) {
function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) {
const center = placement.position;
const stats = metadata.scene_stats || {};
const failed = assets.filter((asset) => asset.error);
@@ -747,6 +1065,7 @@
"Camera range: " + Math.round(distance) + " m",
"Assets: " + liveAssets(assets).length + " model(s)",
"Vehicles: " + cruise.vehicles.length,
"Signals: " + trafficSignals.count,
"Buildings: " + Number(stats.buildings || 0),
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")

View File

@@ -0,0 +1,161 @@
"use strict";
const EARTH_RADIUS_METERS = 6371008.8;
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
const vertices = ring.slice(0, -1);
if (!vertices.every(validCoordinate)) return null;
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
const centerline = vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
return polylineLength(centerline) > 0.01 ? centerline : null;
}
function orientPolyline(polyline, reference) {
if (!polyline?.length || !reference?.length) return null;
const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]);
if (Math.abs(forward) < 0.01) return null;
return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate);
}
function stitchPolylines(polylines, maxGapMeters) {
if (!polylines.length) return null;
const result = [];
for (const polyline of polylines) {
if (!polyline?.length) return null;
if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null;
appendCoordinates(result, polyline);
}
return result;
}
function projectedDistanceAlong(polyline, point) {
let traversed = 0;
let best = { distance: Infinity, along: 0, lateral: 0 };
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (distance < best.distance) {
const rightX = dy / length;
const rightY = -dx / length;
best = {
distance,
along: traversed + length * ratio,
lateral: offsetX * rightX + offsetY * rightY,
};
}
traversed += length;
}
return best.along;
}
function lateralOffsetFrom(polyline, point) {
let best = null;
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (!best || distance < best.distance) {
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length };
}
}
return best;
}
function polylineMidpoint(polyline) {
const target = polylineLength(polyline) / 2;
let traversed = 0;
for (let index = 1; index < polyline.length; index += 1) {
const length = haversineMeters(polyline[index - 1], polyline[index]);
if (traversed + length >= target) {
const ratio = length ? (target - traversed) / length : 0;
return [
polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio,
polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio,
];
}
traversed += length;
}
return polyline.length ? copyCoordinate(polyline.at(-1)) : null;
}
function polylineLength(polyline) {
let total = 0;
for (let index = 1; index < (polyline?.length || 0); index += 1) {
total += haversineMeters(polyline[index - 1], polyline[index]);
}
return total;
}
function haversineMeters(a, b) {
const lat1 = degreesToRadians(a[1]);
const lat2 = degreesToRadians(b[1]);
const dLat = degreesToRadians(b[1] - a[1]);
const dLon = degreesToRadians(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
}
function appendCoordinates(target, coordinates) {
for (const coordinate of coordinates) {
if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate));
}
}
function validCoordinate(value) {
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
}
function sameCoordinate(a, b) {
return Boolean(a && b && a[0] === b[0] && a[1] === b[1]);
}
function copyCoordinate(coordinate) {
return [coordinate[0], coordinate[1]];
}
function metersAt(latitude) {
return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 };
}
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
projectedDistanceAlong,
stitchPolylines,
};

102
scripts/lib/osm.js Normal file
View File

@@ -0,0 +1,102 @@
"use strict";
function parseOsm(xml) {
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
const candidateBounds = {
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
};
const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null;
const nodes = new Map();
const trafficSignalControls = [];
const nodePattern = /<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g;
for (const match of xml.matchAll(nodePattern)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (!coordinate.every(Number.isFinite)) continue;
nodes.set(attrs.id, coordinate);
const tags = parseTags(match[2] || "");
if (tags.highway === "traffic_signals") {
trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags });
}
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete") continue;
const body = match[2];
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
const ref = xmlAttrs(ndMatch[1]).ref;
if (ref && nodes.has(ref)) refs.push(ref);
}
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags: parseTags(body) });
}
for (const control of trafficSignalControls) {
const arms = [];
for (const way of ways) {
if (!isMotorRoad(way.tags)) continue;
for (let index = 0; index < way.refs.length; index += 1) {
if (way.refs[index] !== control.id) continue;
for (const neighborIndex of [index - 1, index + 1]) {
const neighbor = way.refs[neighborIndex];
if (!neighbor || !nodes.has(neighbor)) continue;
const neighborPoint = nodes.get(neighbor);
arms.push({
headingDegrees: headingBetween(control, neighborPoint),
wayId: String(way.id),
neighborNodeId: String(neighbor),
});
}
}
}
control.arms = dedupeHeadings(arms);
control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other";
}
return { bounds, nodes, ways, trafficSignalControls };
}
function isMotorRoad(tags) {
const highway = tags.highway || "";
return highway && tags.area !== "yes" && !new Set([
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
"bridleway", "corridor", "elevator", "platform", "construction",
]).has(highway);
}
function headingBetween(from, to) {
const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180;
return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI;
}
function dedupeHeadings(arms) {
const normalized = (value) => ((value % 360) + 360) % 360;
const distance = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const result = [];
for (const arm of arms) {
arm.headingDegrees = normalized(arm.headingDegrees);
if (!result.some((other) => distance(other.headingDegrees, arm.headingDegrees) <= 25)) result.push(arm);
}
return result.sort((a, b) => a.headingDegrees - b.headingDegrees);
}
function xmlAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
}
return attrs;
}
function parseTags(body) {
const tags = {};
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = xmlAttrs(match[1]);
if (tag.k) tags[tag.k] = tag.v || "";
}
return tags;
}
module.exports = { parseOsm };

View File

@@ -97,6 +97,17 @@ const SCENE_LAYERS = [
},
];
// Editable control layers share the GeoPackage/QGIS lifecycle but never enter
// the merged render scene or its draw order.
const AUXILIARY_EDIT_LAYERS = [
{
id: "traffic_signal_assemblies",
file: "traffic_signal_assemblies.geojson",
title: "traffic signal assemblies",
geometry: "Point",
},
];
const SCENE_FILE = "osm2streets_scene.geojson";
const SCENE_STYLE_FILE = "osm2streets_scene_style.json";
@@ -155,6 +166,7 @@ function qgisRgba(hex, alpha = 255) {
module.exports = {
SCENE_LAYERS,
AUXILIARY_EDIT_LAYERS,
SCENE_FILE,
SCENE_STYLE_FILE,
layerFile,

58
scripts/lib/tool-paths.js Normal file
View File

@@ -0,0 +1,58 @@
"use strict";
const path = require("path");
function qgisPaths(qgisApp) {
if (process.platform === "darwin") {
const macosDir = path.join(qgisApp, "Contents", "MacOS");
const resourcesDir = path.join(qgisApp, "Contents", "Resources");
return {
python: path.join(macosDir, "python3.12"),
ogr2ogr: path.join(macosDir, "ogr2ogr"),
ogrinfo: path.join(macosDir, "ogrinfo"),
prefix: macosDir,
env: {
PROJ_LIB: path.join(resourcesDir, "qgis", "proj"),
GDAL_DATA: path.join(resourcesDir, "qgis", "gdal"),
},
pythonEnv: {
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
PYTHONPATH: [
path.join(resourcesDir, "python"),
path.join(resourcesDir, "python3.11", "site-packages"),
].join(path.delimiter),
DYLD_LIBRARY_PATH: [macosDir, path.join(qgisApp, "Contents", "Frameworks")].join(path.delimiter),
},
};
}
const binDir = path.join(qgisApp, "bin");
const shareDir = path.join(qgisApp, "share");
return {
python: path.join(binDir, "python3"),
ogr2ogr: path.join(binDir, "ogr2ogr"),
ogrinfo: path.join(binDir, "ogrinfo"),
prefix: qgisApp,
env: {
PROJ_LIB: path.join(shareDir, "proj"),
GDAL_DATA: path.join(shareDir, "gdal"),
},
pythonEnv: {
PYTHONPATH: [
path.join(shareDir, "qgis", "python"),
path.join(shareDir, "qgis", "python", "plugins"),
].join(path.delimiter),
},
};
}
function blenderExecutable(blenderApp) {
return process.platform === "darwin"
? path.join(blenderApp, "Contents", "MacOS", "Blender")
: blenderApp;
}
module.exports = {
blenderExecutable,
qgisPaths,
};

View File

@@ -0,0 +1,268 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const { parseOsm } = require("./osm");
const EARTH_RADIUS = 6371008.8;
const CURB_OFFSET_METERS = 5.2;
const MAST_REACH_METERS = 4.5;
const SIGNAL_LAYOUT = Object.freeze({
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,
});
function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
const centers = (intersections.features || []).map((feature, index) => {
const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
}).filter((entry) => entry.point);
const candidates = [];
for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry);
if (!center) continue;
const intersection = nearestCenter(center, centers);
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
const axis = roadAxis(feature.geometry, center, intersection.point);
if (!axis) continue;
const right = [axis[1], -axis[0]];
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
candidates.push({
intersectionId: intersection.id, center, axis,
point: moveMeters(farSide, right, CURB_OFFSET_METERS),
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
});
}
const features = [];
for (const control of controls) {
const controlPoint = [Number(control.longitude), Number(control.latitude)];
if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue;
const intersection = nearestCenter(controlPoint, centers);
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
const groups = phaseGroups(arms);
arms.forEach((candidate, index) => {
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
const approachId = `${sourceWayId}:${neighborNodeId}`;
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: candidate.point.slice() },
properties: {
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
approach_id: approachId, source_way_id: sourceWayId,
heading_deg: candidate.headingDegrees, phase_group: groups[index],
mast_reach_m: MAST_REACH_METERS,
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
enabled: true, z_offset_m: 0,
},
});
});
}
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
}
function validateTrafficSignalFeatures(collection) {
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error("Traffic signal assemblies must be a FeatureCollection");
}
const uids = new Set();
const displayIds = new Set();
const features = collection.features.map((feature, index) => {
const label = `traffic signal feature ${index + 1}`;
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
throw new Error(`${label}: geometry must be a finite Point`);
}
const input = feature.properties || {};
const text = (key, required = true) => {
const value = input[key] == null ? "" : String(input[key]).trim();
if (required && !value) throw new Error(`${label}: missing ${key}`);
return value;
};
const number = (key, options = {}) => {
if (input[key] === null || input[key] === undefined || input[key] === "") {
throw new Error(`${label}: missing ${key}`);
}
const value = Number(input[key]);
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
}
return value;
};
const signalUid = text("signal_uid");
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
uids.add(signalUid);
const displayId = text("display_id", false);
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
if (displayId) displayIds.add(displayId);
const phaseGroup = number("phase_group", { min: 0, max: 1 });
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
const enabled = normalizeBoolean(input.enabled, label);
const controlId = text("control_id");
const approachId = text("approach_id");
const sourceWayId = text("source_way_id");
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
return {
type: "Feature",
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
properties: {
...input, signal_uid: signalUid, display_id: displayId,
control_id: controlId, approach_id: approachId,
source_way_id: sourceWayId, heading_deg: normalizeDegrees(number("heading_deg")),
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
stop_lon: number("stop_lon", { min: -180, max: 180 }),
stop_lat: number("stop_lat", { min: -90, max: 90 }),
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
},
};
});
return { type: "FeatureCollection", features };
}
function buildTrafficSignalsFromFeatures(collection) {
const normalized = validateTrafficSignalFeatures(collection);
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
const p = feature.properties;
const point = feature.geometry.coordinates;
const axis = headingVector(p.heading_deg);
return {
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
nodeKey: signalNodeKey(p.signal_uid),
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
headingDegrees: p.heading_deg, mastReachMeters: p.mast_reach_m,
zOffsetMeters: p.z_offset_m,
pose: buildSignalPose(point, axis, p.mast_reach_m, p.z_offset_m),
};
});
return { version: 3, layout: SIGNAL_LAYOUT, signals };
}
function signalNodeKey(signalUid) {
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
}
function validateTrafficSignalSourceReferences(collection, controls) {
const normalized = validateTrafficSignalFeatures(collection);
const approachesByControl = new Map((controls || []).map((control) => [
String(control.id),
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
]));
for (const [index, feature] of normalized.features.entries()) {
const { control_id: controlId, approach_id: approachId } = feature.properties;
const approaches = approachesByControl.get(controlId);
if (!approaches) {
throw new Error(`traffic signal feature ${index + 1}: control_id '${controlId}' is not present in the current OSM`);
}
if (!approaches.has(approachId)) {
throw new Error(
`traffic signal feature ${index + 1}: approach_id '${approachId}' is not present on OSM control '${controlId}'`,
);
}
}
return normalized;
}
function buildTrafficSignals(stopLines, intersections, controls = []) {
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
}
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
return buildTrafficSignalFeatures(
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
);
}
function readTrafficSignals(editablePath, osmPath = null) {
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
if (osmPath) {
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
validateTrafficSignalSourceReferences(collection, controls);
}
return buildTrafficSignalsFromFeatures(collection);
}
function normalizeBoolean(value, label) {
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
throw new Error(`${label}: invalid enabled '${value}'`);
}
function uniqueApproachArms(candidates, controlPoint) {
const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) }))
.sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
const arms = [];
for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
return arms;
}
function matchOsmArms(candidates, controlPoint, osmArms) {
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
return osmArms.map((osmArm) => {
let bestIndex = -1; let bestDistance = Infinity;
remaining.forEach((item, index) => { const distance = angularDistance(item.armHeading, osmArm.headingDegrees); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } });
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
return { ...candidate, osmArm };
});
}
function fallbackCandidate(controlPoint, osmArm) {
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true };
}
function phaseGroups(arms) {
const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups;
let main = [0, 1]; let best = -1;
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
}
function buildSignalPose(pole, axis, mastReach, zOffset = 0) {
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: height + zOffset });
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 polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; }
function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; }
function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; }
function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; }
function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; }
function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }
function normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); }
module.exports = {
SIGNAL_LAYOUT,
signalNodeKey,
buildTrafficSignalFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
buildTrafficSignalsFromFeatures,
buildTrafficSignals,
readTrafficSignalFeatures,
readTrafficSignals,
};

View File

@@ -2,6 +2,7 @@
const fs = require("fs");
const path = require("path");
const { laneCenterline } = require("./lane-geometry");
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json");
const LANE_WIDTH_METERS = 3.2;
@@ -262,23 +263,6 @@ function closestPointOnSegment(point, start, end, meters) {
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
// A straight osm2streets Driving lane is commonly a closed quadrilateral:
// four distinct vertices plus the repeated closing vertex. Its opposing
// edges still provide the same two-point centerline as longer lane shapes.
if (!ring || ring.length < 5) return null;
// osm2streets Driving polygons are ordered along one boundary then back
// along the other. Midpoints of paired vertices form the rendered lane axis.
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
function axisForLane(ordered, meters) {
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
}

View File

@@ -1,72 +1,119 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("./osm");
const {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
} = require("./lane-geometry");
const MAX_ROUTES = 5;
const MAX_PATH_EDGES = 7;
const MIN_ROUTE_EDGES = 3;
const LANE_OFFSET_METERS = 1.3;
const MAX_LANE_DISTANCE_METERS = 20;
const MIN_LATERAL_SEPARATION_METERS = 0.25;
const JUNCTION_TRIM_METERS = 6.0;
const CONNECTOR_SURFACE_TOLERANCE_METERS = 0.35;
const ALL_TURNS = new Set(["left", "through", "right"]);
function buildVehicleRoute(osmPath) {
function buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) {
if (!lanePolygonsPath || !networkPath || !intersectionSurfacePath) {
throw new Error("Lane polygons, osm2streets network, and intersection surface paths are required for vehicle route generation");
}
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
const edges = directedRoadEdges(osm.ways, osm.nodes, osm.bounds);
const routes = selectRoutes(findReturnRoutes(edges));
const lanePolygons = readLanePolygons(lanePolygonsPath);
const network = readJsonObject(networkPath, "osm2streets network");
const intersectionSurfaces = readFeatureCollection(intersectionSurfacePath, "intersection surfaces");
const diagnostics = [];
const laneIndex = indexDrivingLanes(lanePolygons.features, diagnostics);
const intersections = indexIntersections(network, intersectionSurfaces.features);
const edges = directedRoadEdges(network, osm.ways, diagnostics);
const candidates = findReturnRoutes(edges);
const routes = [];
for (const candidate of candidates) {
const route = makeRoute(candidate, laneIndex, intersections, diagnostics);
if (route) routes.push(route);
}
const selected = selectRoutes(routes);
return {
source: osmPath,
laneSource: lanePolygonsPath,
networkSource: networkPath,
intersectionSource: intersectionSurfacePath,
bounds: osm.bounds,
generatedAt: new Date().toISOString(),
speedMetersPerSecond: 8.0,
loop: true,
routes,
// Older previews read `segments`; keep it as an alias while new previews
// use the more accurate route name.
segments: routes,
routes: selected,
diagnostics,
// 旧预览仍读取 segments保持与 routes 为同一个数组引用。
segments: selected,
};
}
function parseOsm(xml) {
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
const bounds = {
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
};
const validBounds = Object.values(bounds).every(Number.isFinite) ? bounds : null;
const nodes = new Map();
for (const match of xml.matchAll(/<node\b([^>]*)\/?\s*>/g)) {
const attrs = xmlAttrs(match[1]);
if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coord = [Number(attrs.lon), Number(attrs.lat)];
if (coord.every(Number.isFinite)) nodes.set(attrs.id, coord);
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
const body = match[2];
const tags = {};
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = xmlAttrs(tagMatch[1]);
if (tag.k) tags[tag.k] = tag.v || "";
}
if (!isCruiseHighway(tags)) continue;
const refs = [];
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
const ref = xmlAttrs(ndMatch[1]).ref;
if (ref && nodes.has(ref)) refs.push(ref);
}
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags });
}
return { bounds: validBounds, nodes, ways };
function readLanePolygons(file) {
return readFeatureCollection(file, "lane polygons");
}
function xmlAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
function readFeatureCollection(file, label) {
const collection = readJsonObject(file, label);
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error(`Invalid ${label} GeoJSON '${file}': expected FeatureCollection`);
}
return attrs;
return collection;
}
function readJsonObject(file, label) {
try {
const value = JSON.parse(fs.readFileSync(file, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected JSON object");
return value;
} catch (error) {
throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`);
}
}
function indexDrivingLanes(features, diagnostics) {
const index = new Map();
features.forEach((feature, featureIndex) => {
if (feature?.properties?.type !== "Driving") return;
const centerline = laneCenterline(feature);
const direction = feature.properties.direction;
const widthMeters = Number(feature.properties.width);
const road = Number(feature.properties.road);
if (!centerline || !["Fwd", "Back"].includes(direction) || !Number.isFinite(widthMeters) || widthMeters <= 0 || !Number.isInteger(road)) {
diagnostics.push({
reason: "invalid_lane_polygon",
featureIndex,
road: feature?.properties?.road ?? null,
laneIndex: feature?.properties?.index ?? null,
});
return;
}
const lane = {
featureIndex,
polygonId: feature.id ?? `${feature.properties.road ?? "road"}:${direction}:${feature.properties.index ?? featureIndex}`,
road,
laneIndex: feature.properties.index,
widthMeters,
allowedTurns: normalizeAllowedTurns(feature.properties.allowed_turns),
centerline,
};
const key = laneKey(road, direction);
if (!index.has(key)) index.set(key, []);
index.get(key).push(lane);
});
return index;
}
function normalizeAllowedTurns(value) {
if (!Array.isArray(value)) return new Set();
return new Set(value.map(normalizeTurn).filter(Boolean));
}
function isCruiseHighway(tags) {
@@ -78,69 +125,100 @@ function isCruiseHighway(tags) {
]).has(highway);
}
function directedRoadEdges(ways, nodes, bounds) {
function directedRoadEdges(network, ways, diagnostics) {
if (!Array.isArray(network.roads) || !network.gps_bounds) {
throw new Error("Invalid osm2streets network: expected roads and gps_bounds");
}
const waysById = new Map(ways.map((way) => [String(way.id), way]));
const edges = [];
for (const way of ways) {
const refs = compactRefs(way.refs);
if (refs.length < 2) continue;
const coords = refs.map((ref) => nodes.get(ref));
if (!routeInsideBounds(coords, bounds) || routeLength(coords) < 12) continue;
const oneway = String(way.tags.oneway || "").toLowerCase();
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
if (!isOneWay(oneway)) {
edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
for (const entry of network.roads) {
const road = Array.isArray(entry) ? entry[1] : null;
if (!road || !Number.isInteger(Number(road.id)) || !Array.isArray(road.lane_specs_ltr)) continue;
const wayIds = Array.isArray(road.osm_ids) ? road.osm_ids.map(String) : [];
const sourceWays = wayIds.map((id) => waysById.get(id)).filter(Boolean);
const sourceWay = sourceWays[0] || null;
const tags = sourceWay?.tags || { highway: road.highway_type || "" };
if (!isCruiseHighway(tags)) continue;
if (sourceWays.length > 1 && sourceWays.some((way) => JSON.stringify(way.tags) !== JSON.stringify(sourceWay.tags))) {
addDiagnostic(diagnostics, { reason: "ambiguous_internal_road_source", road: road.id, osmWayIds: wayIds });
continue;
}
const coordinates = networkPolylineToGps(road.center_line, network.gps_bounds);
if (coordinates.length < 2 || routeLength(coordinates) < 12) continue;
const directions = new Set(road.lane_specs_ltr
.filter((lane) => lane.lt === "Driving")
.map((lane) => lane.dir));
if (directions.has("Fwd")) edges.push(makeEdge(road, sourceWay, wayIds, coordinates, "forward"));
if (directions.has("Back")) edges.push(makeEdge(road, sourceWay, wayIds, [...coordinates].reverse(), "backward"));
}
return edges.sort((a, b) => a.id.localeCompare(b.id));
}
function makeEdge(way, refs, coordinates, direction) {
function makeEdge(road, way, wayIds, coordinates, direction) {
const forward = direction === "forward";
const tags = way?.tags || {};
return {
id: `${way.id}:${direction}`,
wayId: way.id,
id: `road-${road.id}:${direction}`,
roadId: Number(road.id),
wayId: wayIds[0] || "",
osmWayIds: wayIds,
direction,
name: way.tags.name || way.tags.highway || "road",
highway: way.tags.highway || "",
oneWay: way.tags.oneway || "",
startNode: refs[0],
endNode: refs[refs.length - 1],
name: road.name || tags.name || road.highway_type || "road",
highway: road.highway_type || tags.highway || "",
oneWay: directionsForRoad(road).size === 1 ? "yes" : "",
startNode: forward ? Number(road.src_i) : Number(road.dst_i),
endNode: forward ? Number(road.dst_i) : Number(road.src_i),
coordinates,
allowedTurns: allowedTurns(way.tags, direction),
allowedTurns: allowedTurns(tags, direction),
turnLanes: turnLanes(tags, direction),
};
}
function directionsForRoad(road) {
return new Set(road.lane_specs_ltr.filter((lane) => lane.lt === "Driving").map((lane) => lane.dir));
}
function networkPolylineToGps(polyline, bounds) {
const points = Array.isArray(polyline?.pts) ? polyline.pts : [];
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
if (!(widthMeters > 0) || !(heightMeters > 0)) return [];
return points.map((point) => {
const x = Number(point.x) / 10000;
const y = Number(point.y) / 10000;
return [
bounds.min_lon + x / widthMeters * (bounds.max_lon - bounds.min_lon),
bounds.min_lat + (bounds.max_lat - bounds.min_lat) * (heightMeters - y) / heightMeters,
];
}).filter((coordinate) => coordinate.every(Number.isFinite));
}
function isOneWay(value) {
return ["yes", "true", "1"].includes(value);
}
function compactRefs(refs) {
return refs.filter((ref, index) => index === 0 || ref !== refs[index - 1]);
}
function routeInsideBounds(coords, bounds) {
if (!bounds) return true;
return coords.some((coord) => insideBounds(coord, bounds));
}
function insideBounds(coord, bounds) {
const pad = 0.00002;
return coord[0] >= bounds.minLon - pad && coord[0] <= bounds.maxLon + pad &&
coord[1] >= bounds.minLat - pad && coord[1] <= bounds.maxLat + pad;
}
function allowedTurns(tags, direction) {
const value = tags[`turn:lanes:${direction}`] || tags["turn:lanes"];
if (!value) return ALL_TURNS;
const turns = new Set();
for (const lane of String(value).split("|")) {
for (const maneuver of lane.split(";")) {
const normalized = maneuver.trim().replace(/^slight_/, "");
if (ALL_TURNS.has(normalized)) turns.add(normalized);
}
}
const lanes = turnLanes(tags, direction);
if (!lanes) return ALL_TURNS;
const turns = new Set(lanes.flatMap((lane) => [...lane]).filter((turn) => ALL_TURNS.has(turn)));
return turns.size ? turns : ALL_TURNS;
}
function turnLanes(tags, direction) {
const value = tags[`turn:lanes:${direction}`] ?? tags["turn:lanes"];
if (value === undefined || value === "") return null;
return String(value).split("|").map((lane) => {
const turns = new Set(String(lane).split(";").map(normalizeTurn).filter(Boolean));
return turns.size ? turns : new Set(ALL_TURNS);
});
}
function normalizeTurn(value) {
const turn = String(value || "").trim().replace(/^slight_/, "");
if (turn === "reverse") return "u_turn";
return [...ALL_TURNS, "u_turn"].includes(turn) ? turn : null;
}
function findReturnRoutes(edges) {
const outgoing = new Map();
const byId = new Map();
@@ -151,14 +229,12 @@ function findReturnRoutes(edges) {
}
const candidates = [];
const seen = new Set();
for (const first of edges) {
walkToTerminal([first], [], outgoing, byId, candidates, seen);
}
for (const first of edges) walkToTerminal([first], [], outgoing, byId, candidates, seen);
return candidates.sort((a, b) => a.signature.localeCompare(b.signature));
}
function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
const current = path[path.length - 1];
const current = path.at(-1);
if (path.length >= MIN_ROUTE_EDGES) {
const route = returnRoute(path, maneuvers, byId);
if (route && !seen.has(route.signature)) {
@@ -180,7 +256,7 @@ function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
}
function classifyConnection(incoming, outgoing) {
if (incoming.wayId === outgoing.wayId) return null;
if (incoming.roadId === outgoing.roadId) return null;
const inVector = directionVector(incoming.coordinates.at(-2), incoming.coordinates.at(-1));
const outVector = directionVector(outgoing.coordinates[0], outgoing.coordinates[1]);
const dot = inVector.x * outVector.x + inVector.y * outVector.y;
@@ -192,7 +268,7 @@ function classifyConnection(incoming, outgoing) {
}
function directionVector(a, b) {
const scale = 111320.0;
const scale = 111320;
const x = (b[0] - a[0]) * scale * Math.cos(degreesToRadians((a[1] + b[1]) / 2));
const y = (b[1] - a[1]) * scale;
const length = Math.hypot(x, y) || 1;
@@ -200,7 +276,7 @@ function directionVector(a, b) {
}
function returnRoute(path, forwardManeuvers, byId) {
const reverse = path.slice().reverse().map((edge) => byId.get(`${edge.wayId}:${oppositeDirection(edge.direction)}`));
const reverse = path.slice().reverse().map((edge) => byId.get(`road-${edge.roadId}:${oppositeDirection(edge.direction)}`));
if (reverse.some((edge) => !edge)) return null;
const returnManeuvers = [];
for (let index = 1; index < reverse.length; index += 1) {
@@ -208,67 +284,312 @@ function returnRoute(path, forwardManeuvers, byId) {
if (!maneuver) return null;
returnManeuvers.push(maneuver);
}
const signature = path.map((edge) => edge.wayId).sort().join(">");
return makeRoute(
[...path, ...reverse],
[...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
const signature = path.map((edge) => edge.roadId).join(">");
return {
edges: [...path, ...reverse],
maneuvers: [...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
forwardEdgeCount: path.length,
signature,
);
};
}
function oppositeDirection(direction) {
return direction === "forward" ? "backward" : "forward";
}
function makeRoute(edges, maneuvers, signature) {
const coordinates = smoothRoute(edges);
function makeRoute(candidate, laneIndex, intersections, diagnostics) {
const selectedLanes = [];
for (let index = 0; index < candidate.edges.length; index += 1) {
const edge = candidate.edges[index];
const match = selectLaneForEdge(edge, candidate.maneuvers[index], laneIndex, {
// 仅去程中的真实路口受 turn:lanes 严格约束;端点调头与展示返程不能被反向标签否决。
enforceTurnRestrictions: index < candidate.forwardEdgeCount - 1,
});
if (!match.ok) {
addDiagnostic(diagnostics, {
reason: match.reason,
routeSignature: candidate.signature,
edgeId: edge.id,
road: edge.roadId,
osmWayId: edge.wayId,
direction: edge.direction,
maneuver: candidate.maneuvers[index],
detail: match.detail,
});
return null;
}
selectedLanes.push(match.lane);
}
const smoothed = smoothLaneRoute(candidate.edges, selectedLanes, intersections);
if (!smoothed.ok) {
addDiagnostic(diagnostics, { reason: smoothed.reason, routeSignature: candidate.signature, ...smoothed.detail });
return null;
}
const coordinates = smoothed.coordinates;
const centerlineCoordinates = smoothRoute(candidate.edges);
const route = {
id: `route-${signature.replace(/[^\w]+/g, "-")}`,
highway: edges[0].highway,
oneWay: edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
edgeIds: edges.map((edge) => edge.id),
maneuvers,
id: `route-${candidate.signature.replace(/[^\w]+/g, "-")}`,
highway: candidate.edges[0].highway,
oneWay: candidate.edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
edgeIds: candidate.edges.map((edge) => edge.id),
maneuvers: candidate.maneuvers,
lengthMeters: routeLength(coordinates),
laneOffsetMeters: LANE_OFFSET_METERS,
coordinates: offsetClosedRouteRight(coordinates, LANE_OFFSET_METERS),
centerlineCoordinates: coordinates,
coordinates,
centerlineCoordinates,
laneSegments: selectedLanes.flatMap((lane, edgeIndex) => lane.fragments.map((fragment) => ({
edgeId: candidate.edges[edgeIndex].id,
osmWayId: candidate.edges[edgeIndex].wayId,
direction: candidate.edges[edgeIndex].direction,
laneIndex: lane.laneIndex,
widthMeters: fragment.widthMeters,
centerOffsetMeters: Number(fragment.centerOffsetMeters.toFixed(3)),
maneuver: candidate.maneuvers[edgeIndex],
source: "lane_polygon_centerline",
polygonId: fragment.polygonId,
featureIndex: fragment.featureIndex,
road: fragment.road,
}))),
connectors: smoothed.connectors,
};
Object.defineProperty(route, "signature", { value: signature });
Object.defineProperty(route, "signature", { value: candidate.signature });
return route;
}
function selectLaneForEdge(edge, maneuver, laneIndex, options = {}) {
const enforceTurnRestrictions = options.enforceTurnRestrictions !== false;
const expectedDirection = edge.direction === "forward" ? "Fwd" : "Back";
const candidates = laneIndex.get(laneKey(edge.roadId, expectedDirection)) || [];
if (!candidates.length) return { ok: false, reason: "missing_lane_polygon" };
const lanes = [];
for (const fragment of candidates) {
const centerline = orientPolyline(fragment.centerline, edge.coordinates);
if (!centerline) return { ok: false, reason: "invalid_lane_polygon", detail: "direction_alignment" };
const midpoint = polylineMidpoint(centerline);
const offset = lateralOffsetFrom(edge.coordinates, midpoint);
if (!offset || offset.distance > MAX_LANE_DISTANCE_METERS) {
return { ok: false, reason: "missing_lane_polygon", detail: "geometry_too_far_from_internal_road" };
}
lanes.push({
laneIndex: fragment.laneIndex,
centerline,
centerOffsetMeters: offset.lateral,
allowedTurns: fragment.allowedTurns,
fragments: [{ ...fragment, centerline, centerOffsetMeters: offset.lateral }],
});
}
lanes.sort((a, b) => a.centerOffsetMeters - b.centerOffsetMeters || String(a.laneIndex).localeCompare(String(b.laneIndex)));
for (let index = 1; index < lanes.length; index += 1) {
if (lanes[index].centerOffsetMeters - lanes[index - 1].centerOffsetMeters < MIN_LATERAL_SEPARATION_METERS) {
return { ok: false, reason: "ambiguous_lane_order" };
}
}
if (enforceTurnRestrictions && edge.turnLanes && edge.turnLanes.length !== lanes.length) {
return { ok: false, reason: "ambiguous_lane_order", detail: "turn_lane_count_mismatch" };
}
let compatible = lanes.filter((lane, index) => laneSupportsManeuver(lane, edge.turnLanes?.[index], maneuver));
if (!compatible.length && !enforceTurnRestrictions) compatible = lanes;
if (!compatible.length) return { ok: false, reason: "no_compatible_turn_lane" };
const chooseLeft = maneuver === "left" || maneuver === "u_turn";
return { ok: true, lane: chooseLeft ? compatible[0] : compatible.at(-1) };
}
function laneSupportsManeuver(lane, osmTurns, maneuver) {
const expected = maneuver === "u_turn" ? "left" : maneuver;
if (osmTurns && !osmTurns.has(expected) && !(maneuver === "u_turn" && osmTurns.has("u_turn"))) return false;
if (lane.allowedTurns.size && !lane.allowedTurns.has(expected) && !(maneuver === "u_turn" && lane.allowedTurns.has("u_turn"))) return false;
return true;
}
function laneKey(roadId, direction) {
return `${String(roadId)}:${direction}`;
}
function addDiagnostic(diagnostics, entry) {
const key = JSON.stringify(entry);
if (!diagnostics.some((current) => JSON.stringify(current) === key)) diagnostics.push(entry);
}
function indexIntersections(network, features) {
if (!Array.isArray(network.intersections)) throw new Error("Invalid osm2streets network: expected intersections");
const surfaces = new Map(features
.filter((feature) => feature?.geometry?.type === "Polygon" && Number.isInteger(Number(feature.properties?.id)))
.map((feature) => [Number(feature.properties.id), feature.geometry.coordinates[0]]));
const intersections = new Map();
for (const entry of network.intersections) {
const intersection = Array.isArray(entry) ? entry[1] : null;
if (!intersection || !Number.isInteger(Number(intersection.id))) continue;
intersections.set(Number(intersection.id), {
id: Number(intersection.id),
osmNodeIds: Array.isArray(intersection.osm_ids) ? intersection.osm_ids.map(String) : [],
surface: surfaces.get(Number(intersection.id)) || null,
});
}
return intersections;
}
function smoothLaneRoute(edges, selectedLanes, intersections) {
const route = [];
const connectors = [];
for (let index = 0; index < edges.length; index += 1) {
const current = selectedLanes[index].centerline;
appendCoordinates(route, current);
const nextIndex = (index + 1) % edges.length;
const next = selectedLanes[nextIndex].centerline;
const incomingEdge = edges[index];
const outgoingEdge = edges[nextIndex];
if (incomingEdge.endNode !== outgoingEdge.startNode) {
return { ok: false, reason: "disconnected_internal_roads", detail: { fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId } };
}
const intersection = intersections.get(incomingEdge.endNode);
if (!intersection?.surface) {
return { ok: false, reason: "missing_intersection_surface", detail: { intersectionId: incomingEdge.endNode } };
}
const isUTurn = incomingEdge.roadId === outgoingEdge.roadId;
const turn = constrainedConnector(current, next, incomingEdge.coordinates.at(-1), intersection.surface, isUTurn);
if (!turn) {
return {
ok: false,
reason: "connector_outside_intersection",
detail: { intersectionId: intersection.id, fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId },
};
}
appendCoordinates(route, turn.slice(1));
connectors.push({
intersectionId: intersection.id,
osmNodeIds: intersection.osmNodeIds,
fromRoad: incomingEdge.roadId,
toRoad: outgoingEdge.roadId,
maneuver: isUTurn ? "u_turn" : classifyConnection(incomingEdge, outgoingEdge),
source: "intersection_surface_constrained",
coordinates: turn,
});
}
if (route.length) route[route.length - 1] = [...route[0]];
return { ok: true, coordinates: route, connectors };
}
function constrainedConnector(incoming, outgoing, junction, surface, isUTurn) {
const scales = isUTurn ? [1, 0.8, 0.6, 0.4, 0.25] : [1, 0.75, 0.5, 0.3, 0.15];
for (const scale of scales) {
const connector = isUTurn
? uTurnConnector(incoming, outgoing, junction, 20, scale)
: tangentBezierTurn(incoming, outgoing, 16, scale);
if (connector.length && connector.every((point) => pointInPolygonOrNear(point, surface, CONNECTOR_SURFACE_TOLERANCE_METERS))) {
return connector;
}
}
return null;
}
function pointInPolygonOrNear(point, ring, toleranceMeters) {
if (!Array.isArray(ring) || ring.length < 4) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const a = ring[i];
const b = ring[j];
if ((a[1] > point[1]) !== (b[1] > point[1]) &&
point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
if (distanceToSegmentMeters(point, a, b) <= toleranceMeters) return true;
}
return inside;
}
function distanceToSegmentMeters(point, start, end) {
const latitude = (point[1] + start[1] + end[1]) / 3;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const px = (point[0] - start[0]) * metersLon;
const py = (point[1] - start[1]) * 111320;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return Math.hypot(px - dx * ratio, py - dy * ratio);
}
function smoothRoute(edges) {
const trimmed = edges.map((edge) => trimPolyline(edge.coordinates, JUNCTION_TRIM_METERS));
const route = [];
for (let index = 0; index < edges.length; index += 1) {
appendCoordinates(route, trimmed[index]);
const nextIndex = (index + 1) % edges.length;
const junction = edges[index].coordinates.at(-1);
const turn = edges[index].wayId === edges[nextIndex].wayId
? uTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0])
: bezierTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0], 6);
const turn = edges[index].roadId === edges[nextIndex].roadId
? uTurnConnector(trimmed[index], trimmed[nextIndex], edges[index].coordinates.at(-1))
: tangentBezierTurn(trimmed[index], trimmed[nextIndex]);
appendCoordinates(route, turn.slice(1));
}
if (route.length) route[route.length - 1] = [...route[0]];
return route;
}
function uTurn(start, junction, end) {
const tangent = directionVector(start, junction);
const left = offsetCoordinate(junction, -tangent.y * 3.0, tangent.x * 3.0);
const right = offsetCoordinate(junction, tangent.y * 3.0, -tangent.x * 3.0);
return [
start,
lerpCoordinate(start, junction, 0.72),
left,
right,
lerpCoordinate(end, junction, 0.72),
end,
];
function tangentBezierTurn(incoming, outgoing, samples = 16, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const intersection = intersectTangentRays(start, end, incomingTangent, outgoingTangent);
let controlA;
let controlB;
if (intersection && intersection.a >= 0 && intersection.b >= 0) {
// 两条车道切线的前向交点定义了转弯的几何目标Bezier 控制点取三分之一距离。
const maxA = Math.min(8, Math.max(0.75, incomingSpan * 2.4));
const maxB = Math.min(8, Math.max(0.75, outgoingSpan * 2.4));
const distanceA = Math.min(intersection.a, maxA) * scale;
const distanceB = Math.min(intersection.b, maxB) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * distanceA / 3, incomingTangent.y * distanceA / 3);
controlB = offsetCoordinate(end, -outgoingTangent.x * distanceB / 3, -outgoingTangent.y * distanceB / 3);
} else {
// 平行、反向或交点在车道后方时,使用受限 fallback避免生成反向回环。
const chordMeters = haversineMeters(start, end);
const controlMeters = boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, 0.42, 8) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
}
return cubicBezier(start, controlA, controlB, end, samples);
}
function intersectTangentRays(start, end, incomingTangent, outgoingTangent) {
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const qx = (end[0] - start[0]) * metersLon;
const qy = (end[1] - start[1]) * 111320;
const cross = incomingTangent.x * outgoingTangent.y - incomingTangent.y * outgoingTangent.x;
if (Math.abs(cross) < 1e-6) return null;
const crossQOutgoing = qx * outgoingTangent.y - qy * outgoingTangent.x;
const crossQIncoming = qx * incomingTangent.y - qy * incomingTangent.x;
return {
a: crossQOutgoing / cross,
b: crossQIncoming / cross,
};
}
function uTurnConnector(incoming, outgoing, junction, samples = 20, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const chordMeters = haversineMeters(start, end);
const approachMeters = Math.max(haversineMeters(start, junction), haversineMeters(end, junction));
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const availableMeters = Math.max(0.5, Math.min(10, incomingSpan * 0.8, outgoingSpan * 0.8));
const controlMeters = Math.min(availableMeters, Math.max(Math.min(2, availableMeters), chordMeters * 1.1, approachMeters * 0.6)) * scale;
const controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
const controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
return cubicBezier(start, controlA, controlB, end, samples);
}
function boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, ratio, maximumMeters) {
const lowerMeters = Math.min(1.5, chordMeters * 0.35);
const upperMeters = Math.max(0.25, Math.min(maximumMeters, chordMeters * 0.65, incomingSpan * 0.8, outgoingSpan * 0.8));
return Math.min(upperMeters, Math.max(lowerMeters, chordMeters * ratio));
}
function offsetCoordinate(coord, eastMeters, northMeters) {
const metersPerLat = 111320.0;
const metersPerLat = 111320;
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
}
@@ -290,9 +611,7 @@ function pointAlong(coords, distance) {
return [...coords.at(-1)];
}
function bezierTurn(start, junction, end, samples) {
const controlA = lerpCoordinate(start, junction, 0.72);
const controlB = lerpCoordinate(end, junction, 0.72);
function cubicBezier(start, controlA, controlB, end, samples) {
const points = [];
for (let index = 0; index <= samples; index += 1) {
const t = index / samples;
@@ -305,19 +624,6 @@ function bezierTurn(start, junction, end, samples) {
return points;
}
function appendCoordinates(target, coordinates) {
for (const coord of coordinates) {
const last = target.at(-1);
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) target.push([...coord]);
}
}
function offsetClosedRouteRight(coords, offset) {
const shifted = offsetPolylineRight(coords, offset);
if (shifted.length) shifted[shifted.length - 1] = [...shifted[0]];
return shifted;
}
function selectRoutes(candidates) {
const selected = [];
const covered = new Set();
@@ -336,43 +642,25 @@ function routeScore(route, covered) {
return novelty * 100000 + route.lengthMeters;
}
function offsetPolylineRight(coords, offsetMeters) {
if (coords.length < 2 || offsetMeters === 0) return coords.map((coord) => [...coord]);
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
const metersPerLat = 111320.0;
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
const points = coords.map((coord) => ({ x: coord[0] * metersPerLon, y: coord[1] * metersPerLat, lon: coord[0], lat: coord[1] }));
return points.map((point, index) => {
const prev = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const length = Math.hypot(next.x - prev.x, next.y - prev.y);
if (length < 0.001) return [point.lon, point.lat];
const dx = (next.x - prev.x) / length;
const dy = (next.y - prev.y) / length;
return [(point.x + dy * offsetMeters) / metersPerLon, (point.y - dx * offsetMeters) / metersPerLat];
});
}
function routeLength(coords) {
let total = 0;
for (let index = 1; index < coords.length; index += 1) total += haversineMeters(coords[index - 1], coords[index]);
return total;
}
function haversineMeters(a, b) {
const radius = 6371008.8;
const lat1 = degreesToRadians(a[1]);
const lat2 = degreesToRadians(b[1]);
const dLat = degreesToRadians(b[1] - a[1]);
const dLon = degreesToRadians(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
return polylineLength(coords);
}
function lerpCoordinate(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
function degreesToRadians(value) { return value * Math.PI / 180; }
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = { buildVehicleRoute, classifyConnection, allowedTurns };
module.exports = {
allowedTurns,
buildVehicleRoute,
classifyConnection,
readLanePolygons,
selectLaneForEdge,
tangentBezierTurn,
turnLanes,
uTurnConnector,
};

View File

@@ -18,6 +18,7 @@ const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { spawnSync } = require("child_process");
const { blenderExecutable } = require("./lib/tool-paths");
const repoRoot = path.resolve(__dirname, "..");
const baselineRoot = path.join(repoRoot, "outputs", "_refactor-baseline");
@@ -169,8 +170,8 @@ function fileStat(file) {
}
function blendDigest(config, blend, outFile) {
const blenderApp = config.blenderApp || "/Applications/Blender.app";
const blender = path.join(blenderApp, "Contents", "MacOS", "Blender");
const blenderApp = config.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender");
const blender = blenderExecutable(blenderApp);
const run = spawnSync(blender, [
"--background", "--factory-startup",
"--python", path.join(repoRoot, "blender", "tools", "scene_digest.py"),

View File

@@ -16,8 +16,14 @@ const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const { qgisPaths } = require("./lib/tool-paths");
const { parseOsm } = require("./lib/osm");
const {
validateTrafficSignalSourceReferences,
} = require("./lib/traffic-signals");
const {
SCENE_LAYERS,
AUXILIARY_EDIT_LAYERS,
SCENE_FILE,
SCENE_STYLE_FILE,
layerFile,
@@ -27,12 +33,16 @@ const {
const args = parseArgs(process.argv.slice(2));
const config = loadConfig(args);
const qgisApp = config.qgisApp || "/Applications/QGIS.app";
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr");
const ogrinfo = path.join(qgisMacOS, "ogrinfo");
const qgisApp = config.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr");
const qgis = qgisPaths(qgisApp);
const ogr2ogr = qgis.ogr2ogr;
const ogrinfo = qgis.ogrinfo;
const outDir = path.resolve(requireText(config.outDir, "outDir"));
const gpkgPath = path.resolve(requireText(config.gpkg, "gpkg"));
const inputPath = path.resolve(requireText(config.input, "input"));
const trafficSignalAssembliesPath = path.resolve(
config.trafficSignalAssemblies || path.join(outDir, "traffic_signal_assemblies.geojson"),
);
for (const exe of [ogr2ogr, ogrinfo]) {
if (!fs.existsSync(exe)) {
@@ -42,6 +52,9 @@ for (const exe of [ogr2ogr, ogrinfo]) {
if (!fs.existsSync(gpkgPath)) {
throw new Error(`GeoPackage not found: ${gpkgPath}\nRun the intermediates stage first.`);
}
if (!fs.existsSync(inputPath)) {
throw new Error(`Input OSM XML not found: ${inputPath}`);
}
if (!fs.existsSync(outDir)) {
throw new Error(`GeoJSON output directory not found: ${outDir}`);
}
@@ -50,6 +63,7 @@ console.log(`Reimport: ${gpkgPath}`);
console.log(`Target: ${outDir}`);
const present = gpkgLayers();
const trafficSignalControls = parseOsm(fs.readFileSync(inputPath, "utf8")).trafficSignalControls;
const missing = SCENE_LAYERS.filter((layer) => !present.has(layer.id)).map((layer) => layer.id);
if (missing.length) {
throw new Error(
@@ -67,11 +81,25 @@ try {
console.log(`${layer.id}\tfeatures=${collection.features.length}`);
return { layer, stagedPath, collection };
});
const auxiliary = AUXILIARY_EDIT_LAYERS.map((layer) => {
if (!present.has(layer.id)) throw new Error(`GeoPackage is missing auxiliary layer '${layer.id}'`);
const stagedPath = path.join(stagingDir, layer.file);
exportLayer(layer.id, stagedPath);
const collection = readCollection(stagedPath, layer.id);
const validated = validateTrafficSignalSourceReferences(collection, trafficSignalControls);
console.log(`${layer.id}\tfeatures=${validated.features.length}`);
return { layer, stagedPath, collection: validated };
});
for (const item of staged) {
// Copy rather than rename: the staging dir may be on another filesystem.
fs.copyFileSync(item.stagedPath, path.join(outDir, layerFile(item.layer)));
}
for (const item of auxiliary) {
const destination = item.layer.id === "traffic_signal_assemblies"
? trafficSignalAssembliesPath : path.join(outDir, item.layer.file);
fs.copyFileSync(item.stagedPath, destination);
}
const byId = new Map(staged.map((item) => [item.layer.id, item.collection]));
const scene = mergeScene((layer) => byId.get(layer.id));
@@ -116,7 +144,7 @@ function loadConfig(cliArgs) {
}
Object.assign(base, JSON.parse(fs.readFileSync(file, "utf8")));
}
for (const key of ["qgisApp", "outDir", "gpkg"]) {
for (const key of ["qgisApp", "input", "outDir", "gpkg", "trafficSignalAssemblies"]) {
if (cliArgs[key] !== undefined) base[key] = cliArgs[key];
}
return base;
@@ -130,10 +158,7 @@ function requireText(value, key) {
}
function gdalEnv() {
return {
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
};
return qgis.env;
}
function gpkgLayers() {

View File

@@ -6,8 +6,14 @@ const fs = require("fs");
const os = require("os");
const path = require("path");
const { normalizeAreaConfig } = require("./lib/area-config");
const { stageManifestStatus } = require("./lib/area-diagnostics");
const { digestGltf } = require("./glb-digest");
const { evaluateGlbBudget, BUDGETS } = require("./lib/stage-manifest");
const { evaluateGlbBudget, BUDGETS, fileRecord, writeStageManifest } = require("./lib/stage-manifest");
const qgisBuildSource = fs.readFileSync(path.join(__dirname, "build-osm2streets-qgis.js"), "utf8");
assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/);
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
const gltf = {
nodes: [
@@ -36,6 +42,14 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-"));
const input = path.join(tempDir, "input.osm");
fs.writeFileSync(input, "<osm/>");
const base = { id: "test-area", input, outputRoot: tempDir };
assert.equal(
normalizeAreaConfig(base).outputs.trafficSignals,
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signals.json"),
);
assert.equal(
normalizeAreaConfig(base).outputs.trafficSignalAssemblies,
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"),
);
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
assert.throws(
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
@@ -53,6 +67,63 @@ assert.equal(
normalizeAreaConfig({ ...base, budget: { nodes: 1200, reason: "Dense campus vegetation" } }).budget.glbNodes,
1200,
);
const configPath = path.join(tempDir, "area.json");
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`);
const area = normalizeAreaConfig(base);
fs.mkdirSync(area.outputs.geojsonDir, { recursive: true });
for (const file of [area.outputs.glb, area.outputs.metadata, area.outputs.cesiumPreview, area.outputs.vehicleRoute, area.outputs.vehicleModel]) {
fs.writeFileSync(file, "fixture\n");
}
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const emptyFeatureCollection = '{"type":"FeatureCollection","features":[]}\n';
const emptyNetwork = '{"roads":[],"intersections":[],"gps_bounds":{}}\n';
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
const network = path.join(area.outputs.geojsonDir, "network.json");
fs.writeFileSync(network, emptyNetwork);
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
fs.writeFileSync(intersectionSurface, emptyFeatureCollection);
const previewCss = path.join(__dirname, "lib", "cesium-preview.css");
const previewJs = path.join(__dirname, "lib", "cesium-preview.js");
writeStageManifest(area, {
stage: "preview",
status: "ok",
config: configPath,
inputs: {
config: fileRecord(configPath),
osm: fileRecord(input),
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
previewCss: fileRecord(previewCss),
previewJs: fileRecord(previewJs),
},
outputs: {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel),
},
summary: {},
warnings: [],
});
let previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, true);
fs.appendFileSync(lanePolygons, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("lanePolygons")));
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
fs.appendFileSync(network, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("network")));
fs.writeFileSync(network, emptyNetwork);
fs.appendFileSync(intersectionSurface, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("intersectionSurface")));
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Asset budget tests passed.");

View File

@@ -8,10 +8,22 @@ const path = require("path");
const { cesiumPreviewHtml } = require("./lib/area-preview");
const { makeVehicleGltf } = require("./lib/vehicle-model");
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
const {
allowedTurns,
buildVehicleRoute,
classifyConnection,
tangentBezierTurn,
uTurnConnector,
} = require("./lib/vehicle-route");
const { buildTrafficSignals } = require("./lib/traffic-signals");
const { haversineMeters, laneCenterline } = require("./lib/lane-geometry");
const { parseOsm } = require("./lib/osm");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
const osmPath = path.join(tempDir, "fixture.osm");
const lanePolygonsPath = path.join(tempDir, "lane_polygons.geojson");
const networkPath = path.join(tempDir, "network.json");
const intersectionSurfacePath = path.join(tempDir, "intersection_surface.geojson");
fs.writeFileSync(osmPath, `<?xml version="1.0"?>
<osm version="0.6">
@@ -23,11 +35,11 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
<node id="5" lon="120.009" lat="30.002"/>
<way id="west-road">
<nd ref="1"/><nd ref="2"/>
<tag k="highway" v="primary"/><tag k="turn:lanes:forward" v="left|through"/>
<tag k="highway" v="primary"/><tag k="lanes" v="4"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="2"/><tag k="turn:lanes:forward" v="left|through"/>
</way>
<way id="turn-road">
<nd ref="2"/><nd ref="3"/>
<tag k="highway" v="residential"/><tag k="turn:lanes:forward" v="through|left"/>
<tag k="highway" v="residential"/><tag k="lanes" v="3"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="1"/><tag k="turn:lanes:forward" v="through|left"/><tag k="turn:lanes:backward" v="right"/>
</way>
<way id="east-road">
<nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/>
@@ -41,8 +53,44 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
</osm>
`);
const route = buildVehicleRoute(osmPath);
const roadCoordinates = {
"west-road": [[120.001, 30.001], [120.003, 30.001]],
"turn-road": [[120.003, 30.001], [120.005, 30.002]],
"east-road": [[120.005, 30.002], [120.007, 30.002]],
};
const laneFeatures = [
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Back", 3.5, [5.25, 1.75], 0),
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Fwd", 3.5, [1.75, 5.25], 2),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Back", 3.0, [1.5], 0),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Fwd", 3.0, [1.5, 4.5], 1),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Back", 3.5, [1.75], 0),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Fwd", 3.5, [1.75], 1),
{ type: "Feature", properties: { type: "Driving", direction: "Fwd", index: 99, width: 3, road: 99, osm_way_ids: ["broken"] }, geometry: { type: "Polygon", coordinates: [[]] } },
];
fs.writeFileSync(lanePolygonsPath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures })}\n`);
const fixtureBounds = { min_lon: 120, min_lat: 30, max_lon: 120.01, max_lat: 30.01 };
const networkRoads = [
networkRoad(0, "west-road", 0, 1, roadCoordinates["west-road"], [laneSpec("Back", 3.5), laneSpec("Back", 3.5), laneSpec("Fwd", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds, "primary"),
networkRoad(1, "turn-road", 1, 2, roadCoordinates["turn-road"], [laneSpec("Back", 3), laneSpec("Fwd", 3), laneSpec("Fwd", 3)], fixtureBounds),
networkRoad(2, "east-road", 2, 3, roadCoordinates["east-road"], [laneSpec("Back", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds),
networkRoad(3, "oneway-spur", 3, 4, [[120.007, 30.002], [120.009, 30.002]], [laneSpec("Fwd", 3.5)], fixtureBounds),
];
fs.writeFileSync(networkPath, `${JSON.stringify({
roads: networkRoads.map((road) => [road.id, road]),
intersections: [0, 1, 2, 3, 4].map((id) => [id, { id, osm_ids: [String(id + 1)] }]),
gps_bounds: fixtureBounds,
})}\n`);
fs.writeFileSync(intersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 9)),
})}\n`);
const route = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath);
assert.equal(route.source, osmPath);
assert.equal(route.laneSource, lanePolygonsPath);
assert.equal(route.networkSource, networkPath);
assert.equal(route.intersectionSource, intersectionSurfacePath);
assert.deepEqual(route.bounds, {
minLon: 120,
minLat: 30,
@@ -57,16 +105,97 @@ assert.ok(route.routes.every((segment) => segment.edgeIds.length >= 6));
assert.ok(route.routes.every((segment) => segment.maneuvers.includes("u_turn")));
assert.ok(route.routes.every((segment) => segment.maneuvers.some((value) => ["left", "right", "through"].includes(value))));
assert.ok(route.routes.every((segment) => JSON.stringify(segment.coordinates[0]) === JSON.stringify(segment.coordinates.at(-1))));
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("oneway-spur:backward")));
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("road-3:backward")));
assert.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
assert.ok(route.routes.every((segment) => !Object.hasOwn(segment, "laneOffsetMeters")));
assert.ok(route.routes.every((segment) => segment.laneSegments.length >= segment.edgeIds.length));
assert.ok(route.routes.every((segment) => segment.connectors.length === segment.edgeIds.length));
assert.ok(route.routes.flatMap((segment) => segment.connectors).every((connector) =>
connector.source === "intersection_surface_constrained" && connector.coordinates.length >= 2
));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3.5));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).every((segment) =>
segment.source === "lane_polygon_centerline" && Number.isFinite(segment.centerOffsetMeters)
));
for (const segment of route.routes) {
for (const lane of segment.laneSegments) {
const polygonCenterline = laneCenterline(laneFeatures[lane.featureIndex]);
assert.ok(polygonCenterline, "selected lane polygon has a valid centerline");
assert.ok(polygonCenterline.every((point) =>
Math.min(...segment.coordinates.map((coordinate) => haversineMeters(point, coordinate))) <= 0.10
), "route coordinates retain every selected lane centerline point within 0.10 m");
}
}
assert.ok(route.diagnostics.some((entry) => entry.reason === "invalid_lane_polygon"));
const westThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "west-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 3
);
assert.ok(westThrough, "through uses the rightmost compatible lane on west-road");
assert.ok(Math.abs(westThrough.centerOffsetMeters - 5.25) <= 0.01, "3.5 m lane geometry produces the 5.25 m outer-lane center");
const turnThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 1
);
assert.ok(turnThrough, "turn lane restrictions override the default rightmost choice");
assert.ok(Math.abs(turnThrough.centerOffsetMeters - 1.5) <= 0.01, "3.0 m lane geometry produces the 1.5 m inner-lane center");
assert.ok(route.routes.some((segment) => segment.laneSegments.some((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "backward" && lane.maneuver === "through"
)), "display return survives an incompatible reverse turn:lanes tag");
const missingLanePath = path.join(tempDir, "missing-lane.geojson");
fs.writeFileSync(missingLanePath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures.filter((feature) =>
!feature.properties.osm_way_ids.includes("east-road")
) })}\n`);
const missingLaneRoute = buildVehicleRoute(osmPath, missingLanePath, networkPath, intersectionSurfacePath);
assert.equal(missingLaneRoute.routes.length, 0);
assert.ok(missingLaneRoute.diagnostics.some((entry) => entry.reason === "missing_lane_polygon"));
const tinyIntersectionSurfacePath = path.join(tempDir, "tiny-intersection-surface.geojson");
fs.writeFileSync(tinyIntersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 0.1)),
})}\n`);
const rejectedConnectors = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, tinyIntersectionSurfacePath);
assert.equal(rejectedConnectors.routes.length, 0);
assert.ok(rejectedConnectors.diagnostics.some((entry) => entry.reason === "connector_outside_intersection"));
assert.throws(() => buildVehicleRoute(osmPath, path.join(tempDir, "absent.geojson"), networkPath, intersectionSurfacePath), /Invalid lane polygons JSON/);
const quad = laneFeatures[0];
assert.equal(laneCenterline(quad).length, 2);
assert.equal(laneCenterline({ geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [0, 0]]] } }), null);
assert.deepEqual(
[...allowedTurns({ "turn:lanes:forward": "left|through;right" }, "forward")].sort(),
["left", "right", "through"],
);
const incoming = { wayId: "in", coordinates: [[120, 30], [120.001, 30]] };
assert.equal(classifyConnection(incoming, { wayId: "left", coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
assert.equal(classifyConnection(incoming, { wayId: "right", coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
assert.equal(classifyConnection(incoming, { wayId: "through", coordinates: [[120.001, 30], [120.002, 30]] }), "through");
const incoming = { roadId: 1, coordinates: [[120, 30], [120.001, 30]] };
assert.equal(classifyConnection(incoming, { roadId: 2, coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
assert.equal(classifyConnection(incoming, { roadId: 3, coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
assert.equal(classifyConnection(incoming, { roadId: 4, coordinates: [[120.001, 30], [120.002, 30]] }), "through");
const connectorOrigin = [120, 30];
const incomingLane = [metersCoordinate(connectorOrigin, -12, -1.5), metersCoordinate(connectorOrigin, -5, -1.5)];
const leftOutgoingLane = [metersCoordinate(connectorOrigin, 1.5, 5), metersCoordinate(connectorOrigin, 1.5, 12)];
const rightOutgoingLane = [metersCoordinate(connectorOrigin, -1.5, -5), metersCoordinate(connectorOrigin, -1.5, -12)];
for (const [label, outgoingLane] of [["left", leftOutgoingLane], ["right", rightOutgoingLane]]) {
const connector = tangentBezierTurn(incomingLane, outgoingLane);
assert.deepEqual(connector[0], incomingLane.at(-1), `${label} connector retains the incoming lane endpoint`);
assert.deepEqual(connector.at(-1), outgoingLane[0], `${label} connector retains the outgoing lane endpoint`);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), connector[0], connector[1]) < 5,
`${label} connector enters along the incoming lane tangent`);
assert.ok(tangentMismatchDegrees(connector.at(-2), connector.at(-1), outgoingLane[0], outgoingLane[1]) < 5,
`${label} connector exits along the outgoing lane tangent`);
assert.ok(maxStepMeters(connector) < 1.5, `${label} connector sampling has no abnormal position jump`);
}
const uTurnOutgoingLane = [metersCoordinate(connectorOrigin, -5, 1.5), metersCoordinate(connectorOrigin, -12, 1.5)];
const uTurn = uTurnConnector(incomingLane, uTurnOutgoingLane, connectorOrigin);
assert.deepEqual(uTurn[0], incomingLane.at(-1));
assert.deepEqual(uTurn.at(-1), uTurnOutgoingLane[0]);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), uTurn[0], uTurn[1]) < 5,
"U-turn enters along the incoming lane tangent");
assert.ok(tangentMismatchDegrees(uTurn.at(-2), uTurn.at(-1), uTurnOutgoingLane[0], uTurnOutgoingLane[1]) < 5,
"U-turn exits along the outgoing lane tangent");
assert.ok(maxStepMeters(uTurn) < 1, "U-turn sampling has no abnormal position jump");
assert.ok(Math.max(...uTurn.map((coordinate) => eastMeters(connectorOrigin, coordinate))) > -3,
"U-turn forms a forward loop instead of a fixed lateral polyline");
const vehicle = makeVehicleGltf();
assert.equal(vehicle.asset.version, "2.0");
@@ -113,15 +242,192 @@ const html = cesiumPreviewHtml(
"vehicle.gltf",
"north<&>\u2028valley",
["car-a.gltf", "truck-a.gltf"],
"traffic-signals.json",
);
assert.match(html, /<title>north&lt;&amp;&gt;\u2028valley Cesium Preview<\/title>/);
assert.match(html, /Loading scene&lt;&amp;&gt;\.glb/);
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
assert.match(html, /id="toggleSignals"/);
assert.match(html, /id="toggleBuildingGhost"/);
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, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
assert.match(previewRuntime, /setBuildingGhost/);
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);
assert.match(previewRuntime, /syncSelectedRouteVisibility\(cruise\)/);
assert.match(previewRuntime, /buildings\.model\.color = Cesium\.Color\.WHITE\.withAlpha\(0\.22\)/);
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 trafficIntersection = { type: "FeatureCollection", features: [
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
]] } },
] };
const tStopLines = { type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
] };
const control = {
id: "traffic-t", longitude: 120.0001, latitude: 30.00005,
arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }],
};
const noControlSignals = buildTrafficSignals(tStopLines, trafficIntersection);
assert.equal(noControlSignals.signals.length, 0, "untagged intersections must not create traffic signals");
const tSignals = buildTrafficSignals(tStopLines, trafficIntersection, [control]);
assert.equal(tSignals.version, 3);
assert.equal(tSignals.signals.length, 3, "a tagged T junction has one signal per physical approach");
assert.deepEqual(tSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1]);
assert.ok(tSignals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
assert.equal(tSignals.layout.countdownLateralMeters, 1.15);
assert.equal(tSignals.layout.countdownWidthMeters, 0.82);
assert.ok(tSignals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
const crossSignals = buildTrafficSignals(
{ type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
rectangle(120.00010, 29.99985, 0.00003, 0.000006),
] },
trafficIntersection,
[{ ...control, arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }, { headingDegrees: 180 }] }],
);
assert.equal(crossSignals.signals.length, 4, "a tagged cross junction retains all four approaches");
assert.deepEqual(crossSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1, 1]);
const parsedSignalControls = parseOsm(`
<osm><bounds minlon="119" minlat="29" maxlon="121" maxlat="31" />
<node id="active" lon="120" lat="30"><tag k="highway" v="traffic_signals" /><tag k="traffic_signals:direction" v="both" /></node>
<node id="directionless" lon="120" lat="30"><tag k="highway" v="traffic_signals" /></node>
<node id="deleted" lon="120" lat="30" action="delete"><tag k="highway" v="traffic_signals" /></node>
<node id="crossing" lon="120" lat="30"><tag k="highway" v="crossing" /><tag k="crossing" v="traffic_signals" /></node>
</osm>`).trafficSignalControls;
assert.deepEqual(parsedSignalControls.map((entry) => entry.id), ["active", "directionless"], "only active highway=traffic_signals nodes control vehicle signals");
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Preview asset tests passed.");
function rectangle(lon, lat, halfWidth, halfHeight) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
[lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - halfHeight],
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
]] } };
}
function directionalLanes(osmWayId, roadId, coordinates, direction, widthMeters, offsets, firstIndex) {
const oriented = direction === "Fwd" ? coordinates : [...coordinates].reverse();
return offsets.map((offsetMeters, index) => lanePolygon(
osmWayId, roadId, oriented, direction, firstIndex + index, widthMeters, offsetMeters,
));
}
function lanePolygon(osmWayId, roadId, coordinates, direction, index, widthMeters, offsetMeters) {
const centerline = offsetLineRight(coordinates, offsetMeters);
const left = offsetLineRight(centerline, -widthMeters / 2);
const right = offsetLineRight(centerline, widthMeters / 2);
return {
type: "Feature",
properties: {
type: "Driving",
direction,
index,
width: widthMeters,
road: roadId,
osm_way_ids: [osmWayId],
allowed_turns: [],
},
geometry: { type: "Polygon", coordinates: [[...left, ...right.reverse(), left[0]]] },
};
}
function laneSpec(direction, widthMeters) {
return { lt: "Driving", dir: direction, width: widthMeters * 10000, allowed_turns: 0 };
}
function networkRoad(id, osmWayId, src, dst, coordinates, laneSpecs, bounds, highwayType = "residential") {
return {
id,
osm_ids: [osmWayId],
src_i: src,
dst_i: dst,
highway_type: highwayType,
name: osmWayId,
center_line: { pts: coordinates.map((coordinate) => networkPoint(coordinate, bounds)) },
lane_specs_ltr: laneSpecs,
};
}
function networkPoint([lon, lat], bounds) {
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
return {
x: Math.round((lon - bounds.min_lon) / (bounds.max_lon - bounds.min_lon) * widthMeters * 10000),
y: Math.round((heightMeters - (lat - bounds.min_lat) / (bounds.max_lat - bounds.min_lat) * heightMeters) * 10000),
};
}
function intersectionFeature(id, coordinate, halfSizeMeters) {
const west = metersCoordinate(coordinate, -halfSizeMeters, 0)[0];
const east = metersCoordinate(coordinate, halfSizeMeters, 0)[0];
const south = metersCoordinate(coordinate, 0, -halfSizeMeters)[1];
const north = metersCoordinate(coordinate, 0, halfSizeMeters)[1];
return {
type: "Feature",
properties: { id, type: "intersection" },
geometry: { type: "Polygon", coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] },
};
}
function offsetLineRight(coordinates, offsetMeters) {
const [start, end] = coordinates;
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(latitude * Math.PI / 180);
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const length = Math.hypot(dx, dy);
const east = dy / length * offsetMeters;
const north = -dx / length * offsetMeters;
return coordinates.map(([lon, lat]) => [lon + east / metersLon, lat + north / 111320]);
}
function metersCoordinate(origin, east, north) {
const metersLon = 111320 * Math.cos(origin[1] * Math.PI / 180);
return [origin[0] + east / metersLon, origin[1] + north / 111320];
}
function eastMeters(origin, coordinate) {
return (coordinate[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
}
function tangentMismatchDegrees(a, b, c, d) {
const metersLon = 111320 * Math.cos((b[1] + c[1]) / 2 * Math.PI / 180);
const first = [(b[0] - a[0]) * metersLon, (b[1] - a[1]) * 111320];
const second = [(d[0] - c[0]) * metersLon, (d[1] - c[1]) * 111320];
const cosine = (first[0] * second[0] + first[1] * second[1]) / (Math.hypot(...first) * Math.hypot(...second));
return Math.acos(Math.max(-1, Math.min(1, cosine))) * 180 / Math.PI;
}
function maxStepMeters(coordinates) {
return Math.max(...coordinates.slice(1).map((coordinate, index) => haversineMeters(coordinates[index], coordinate)));
}

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const {
buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("./lib/traffic-signals");
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
[lon - dx, lat - dy], [lon + dx, lat - dy], [lon + dx, lat + dy],
[lon - dx, lat + dy], [lon - dx, lat - dy],
]] }, properties: {} };
}
const intersections = { type: "FeatureCollection", features: [rectangle(120.0001, 30.00005, 0.0003, 0.00025)] };
const stops = { type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005), rectangle(120.00010, 30.00025),
rectangle(120.00035, 30.00005), rectangle(120.00010, 29.99985),
] };
const arms = [
{ headingDegrees: 270, wayId: "west", neighborNodeId: "w1" },
{ headingDegrees: 0, wayId: "north", neighborNodeId: "n1" },
{ headingDegrees: 90, wayId: "east", neighborNodeId: "e1" },
{ headingDegrees: 180, wayId: "south", neighborNodeId: "s1" },
];
const control = { id: "control-1", longitude: 120.0001, latitude: 30.00005, arms };
const cross = buildTrafficSignalFeatures(stops, intersections, [control]);
assert.equal(cross.features.length, 4);
assert.equal(new Set(cross.features.map((feature) => feature.properties.signal_uid)).size, 4);
const t = buildTrafficSignalFeatures(stops, intersections, [{ ...control, arms: arms.slice(0, 3) }]);
assert.equal(t.features.length, 3);
assert.deepEqual(
buildTrafficSignalFeatures(stops, intersections, [control]).features.map((feature) => feature.properties.signal_uid),
cross.features.map((feature) => feature.properties.signal_uid),
"technical ids are deterministic",
);
const edited = structuredClone(cross);
const first = edited.features[0];
const originalStop = [first.properties.stop_lon, first.properties.stop_lat];
first.geometry.coordinates[0] += 0.0001;
first.properties.display_id = "A-01";
first.properties.heading_deg = 42;
first.properties.z_offset_m = 1.25;
const runtime = buildTrafficSignalsFromFeatures(edited);
assert.equal(new Set(runtime.signals.map((signal) => signal.nodeKey)).size, runtime.signals.length);
for (const signal of runtime.signals) {
assert.match(signal.nodeKey, /^ts_[0-9a-f]{16}$/);
assert.ok(
`TrafficSignalDynamic_${signal.nodeKey}_countdown_19`.length <= 63,
"dynamic node names must stay below Blender's name limit",
);
}
const changed = runtime.signals.find((signal) => signal.id === first.properties.signal_uid);
assert.equal(changed.displayId, "A-01");
assert.equal(changed.longitude, first.geometry.coordinates[0]);
assert.equal(changed.headingDegrees, 42);
assert.deepEqual([changed.stopLongitude, changed.stopLatitude], originalStop, "moving a pole preserves the stop point");
assert.equal(changed.pose.pole.height, 1.25);
assert.equal(changed.pose.arm.from.height, 7.5);
edited.features[1].properties.enabled = "0";
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
const duplicateUid = structuredClone(cross);
duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid;
assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/);
const duplicateDisplay = structuredClone(cross);
duplicateDisplay.features[1].properties.display_id = duplicateDisplay.features[0].properties.display_id;
assert.throws(() => validateTrafficSignalFeatures(duplicateDisplay), /Duplicate display_id/);
const invalid = structuredClone(cross);
invalid.features[0].properties.mast_reach_m = -1;
assert.throws(() => validateTrafficSignalFeatures(invalid), /invalid mast_reach_m/);
const invalidGeometry = structuredClone(cross);
invalidGeometry.features[0].geometry = { type: "LineString", coordinates: [[120, 30], [121, 31]] };
assert.throws(() => validateTrafficSignalFeatures(invalidGeometry), /geometry must be a finite Point/);
const mismatchedIdentity = structuredClone(cross);
mismatchedIdentity.features[0].properties.approach_id = "other-way:w1";
assert.throws(() => validateTrafficSignalFeatures(mismatchedIdentity), /approach_id does not match source_way_id/);
const invalidEnabled = structuredClone(cross);
invalidEnabled.features[0].properties.enabled = "maybe";
assert.throws(() => validateTrafficSignalFeatures(invalidEnabled), /invalid enabled/);
assert.doesNotThrow(() => validateTrafficSignalSourceReferences(cross, [control]));
assert.throws(
() => validateTrafficSignalSourceReferences(cross, [{ ...control, arms: arms.slice(1) }]),
/approach_id .* is not present on OSM control/,
);
assert.throws(
() => validateTrafficSignalSourceReferences(cross, []),
/control_id .* is not present in the current OSM/,
);
for (const disabledValue of [false, 0, "0", "false", "no"]) {
const disabled = structuredClone(cross);
disabled.features[0].properties.enabled = disabledValue;
assert.equal(buildTrafficSignalsFromFeatures(disabled).signals.length, 3);
}
for (const key of ["heading_deg", "phase_group", "stop_lon", "stop_lat", "z_offset_m"]) {
const missingNumber = structuredClone(cross);
missingNumber.features[0].properties[key] = null;
assert.throws(
() => validateTrafficSignalFeatures(missingNumber),
new RegExp(`missing ${key}`),
`${key} must not silently coerce null to zero`,
);
}
console.log("Traffic signal tests passed.");