48 Commits

Author SHA1 Message Date
ea8a3622b9 chore: record journal 2026-08-14 18:06:00 +08:00
03a8d6159b chore(task): archive 08-14-native-road-lane-markings 2026-08-14 18:05:43 +08:00
1b9829d9ed feat: add native road direction arrows 2026-08-14 18:05:21 +08:00
822e6ef936 feat: report native junction quality metrics 2026-08-14 16:21:26 +08:00
3eea12c6ea fix: stabilize native road Cesium preview 2026-08-14 16:07:55 +08:00
3d57655497 feat: inspect native junctions in workbench 2026-08-14 15:31:48 +08:00
65cf8b96d9 feat: add native road compiler provider 2026-08-14 15:19:57 +08:00
e1f3fc10ca fix: persist and render native sidewalks 2026-08-14 10:53:20 +08:00
f43a122ff7 fix: accept inferred road widths in workbench 2026-08-14 10:43:47 +08:00
3e22a1872e feat: clarify road movement editing feedback 2026-08-14 10:38:22 +08:00
1da985c932 feat: inspect native road movements 2026-08-14 10:26:35 +08:00
817e361477 feat: prioritize road review diagnostics 2026-08-14 10:22:37 +08:00
25287777f6 feat: add native road quality check 2026-08-14 10:18:08 +08:00
a556c0fc97 feat: add canonical road movements 2026-08-14 10:15:47 +08:00
1876472bf8 feat: improve native road diagnostics and review 2026-08-14 10:07:16 +08:00
707e7f82f9 feat: migrate road workbench to OpenLayers 2026-08-14 09:38:56 +08:00
850e9eb344 feat: add lane-level turn controls 2026-08-14 09:22:38 +08:00
df81a22ce8 feat: compile native road lanes and junctions 2026-08-14 09:09:48 +08:00
b5fa4482f0 feat: add native road compiler workbench 2026-08-13 18:01:20 +08:00
ddd15f68b3 chore: record journal 2026-08-12 11:30:54 +08:00
aa5b1f85c8 chore(task): archive 08-12-vehicle-incident-info 2026-08-12 11:30:54 +08:00
761a52646c feat(preview): add vehicle incident cards 2026-08-12 11:29:13 +08:00
f13d890ffb fix(assets): publish controllable traffic signal runtime 2026-08-12 10:17:34 +08:00
41a0e81a6f chore: record journal 2026-08-12 09:09:44 +08:00
eff7a01b2b chore(task): archive 08-11-asset-package-contract 2026-08-12 09:07:57 +08:00
0102ffbb3c docs(assets): document reusable asset package contract 2026-08-12 09:06:04 +08:00
f2b8d79f5d fix(package): record published primary model 2026-08-11 17:52:59 +08:00
db0fba5bb5 fix(pipeline): preserve package manifest ownership 2026-08-11 17:45:16 +08:00
c925890848 fix(preview): resolve package assets from manifest 2026-08-11 16:31:55 +08:00
f385009043 feat(assets): publish reusable area packages 2026-08-11 16:07:07 +08:00
b4a81331e8 chore: record journal 2026-08-11 15:31:34 +08:00
e0cdf9e00b chore(task): archive 08-11-interactive-area-cli 2026-08-11 15:31:34 +08:00
0790cbd0a9 feat(pipeline): make compressed assets the default delivery 2026-08-11 15:30:04 +08:00
b68be063ad feat(cli): add interactive area build menu 2026-08-11 14:08:22 +08:00
d26921c6d1 fix(reimport): retain intersection IDs for cruise routes 2026-08-11 13:25:54 +08:00
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
107 changed files with 7019 additions and 745 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"]`,由
@@ -278,6 +300,7 @@ 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 材质索引整体平移 |

View File

@@ -54,7 +54,7 @@ cp config/examples/template.json config/areas/my-area.json
| `turnLaneArrows` | | 见下 | 从 OSM `turn:lanes:*` 生成自定义车道箭头的发布开关 |
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
| `blender` | | 见下 | Blender 侧选项 |
| `compress` | | 见下 | 显式 `compress` 阶段的 GLB 压缩选项 |
| `compress` | | 见下 | 默认交付压缩阶段的 GLB 压缩选项 |
| `budget` | | 见下 | 区域 GLB 性能与体量预算 |
| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 |
@@ -68,12 +68,14 @@ cp config/examples/template.json config/areas/my-area.json
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 |
| `blender` | `true` | |
| `cesium` | `true` | |
| `compress` | `true` | 压缩 staged GLB供随后发布使用 |
| `package` | `true` | 将通过校验的静态模型原子发布到 `package/` |
| `preview` | `true` | 基于已发布 package 写验证预览及 `_preview/` 动态资源 |
`reimport``preview``compress` **在这里配也没用**——`normalizeAreaConfig` 把它
硬编码为 `false`,只能靠 `--stages` 显式请求。
`reimport` **在这里配也没用**——`normalizeAreaConfig` 把它硬编码为 `false`,只能靠
`--stages` 显式请求`compress``package``preview` 始终默认开启
> 恢复动作reimport、补丁动作preview和替代产物动作compress不该被一份
> 配置文件变成默认行为。
> 恢复动作reimport不该被一份配置文件变成默认行为;压缩、发布和验证预览是标准交付链的一部分。
`--stages` 会整体覆盖这里的默认值。
@@ -130,11 +132,13 @@ cp config/examples/template.json config/areas/my-area.json
|---|---|---|
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py``TREE_STYLES``natural``procedural``shapespark` |
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 |
### `compress`
只影响显式 `--stages compress`。默认压缩链是 texture resize + WebP transcode
不覆盖默认 `<area-id>.glb`
完整构建和显式 `--stages compress` 都使用此配置。默认压缩链是 texture resize + WebP
transcode成功后替换**package staging** 中的主 GLB 与 manifest未压缩源只保留在构建临时目录
只有随后的 `package` 阶段才会原子发布到 `package/`
| 字段 | 默认 | 说明 |
|---|---|---|
@@ -162,14 +166,16 @@ cp config/examples/template.json config/areas/my-area.json
### `outputs`(逃生舱)
默认全部从 `id` 推导`<outputRoot>/<id>/<fileStem>.<ext>`。需要定制时逐项覆盖:
默认全部从 `id` 推导。发布给下游的静态资产固定在
`<outputRoot>/<id>/package/`(含 `models/` 与可控信号灯 `runtime/`),临时静态资产在
`_pipeline/package-staging/`,预览专用车辆/路线资源在 `<outputRoot>/<id>/_preview/`。需要定制时逐项覆盖:
```json
{
"outputs": {
"areaDir": "/absolute/path/to/custom-area",
"blend": "/absolute/path/to/custom.blend",
"glb": "/absolute/path/to/custom.glb",
"packageDir": "/absolute/path/to/custom-package",
"cesiumPreview": "/absolute/path/to/custom-preview.html"
}
}
@@ -177,9 +183,13 @@ cp config/examples/template.json config/areas/my-area.json
可覆盖的键(`scripts/lib/area-config.js``areaDir``fileStem``geojsonDir``gpkg`
`qgisProject``qgisPreview``blend``render``glb``metadata``cesiumPreview`
`compressedFileStem``compressedGlb``compressedMetadata``compressedCesiumPreview`
`vehicleRoute``vehicleModel``pipelineDir``stageManifestDir`
静态发布路径另有 `packageDir``packageStagingDir``packageManifest`
`packageStagingManifest``packageModelDir``packageStagingModelDir`
`packagePrimaryGlb`;预览路径另有 `previewDir``previewDescriptor`。除非在迁移旧调用,
不要覆盖 `glb` / `metadata`:它们是 staging 内部路径,不是下游资产入口。
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
---
@@ -221,7 +231,8 @@ cp config/examples/template.json config/areas/my-area.json
| 布尔字段用 `\|\|` 兜底 | `false` 被翻转 |
| 给新字段造顶层平铺别名 | 扩大历史包袱 |
| 逐个覆盖 `outputs` 而不用 `fileStem` | 漏掉某个产物路径 |
| `stages` 里配 `reimport` / `preview` / `compress` | 无效,被硬编码为 false |
| `glb` / `metadata` 当作下游入口 | 它们位于 staging应只读取 `package/manifest.json` |
| 在 `stages` 里配 `reimport` | 无效,被硬编码为 false |
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
---

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

@@ -21,13 +21,14 @@
全部是 CommonJS`package.json``"type": "commonjs"`),无构建步骤、无 TypeScript、
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
## 可选 GLB 压缩阶段
## 默认 GLB 压缩阶段
### 1. Scope / Trigger
`compress`显式请求的替代产物阶段,不属`all`,也不覆盖默认 `<area-id>.glb`
它用于在已有 Cesium GLB 上生成并列压缩 GLB、metadata 和预览页。当前主路径是
texture resize + WebP transcode。
`compress`完整构建的默认阶段,`cesium``package` 之间。它在
`_pipeline/package-staging/` 的主 GLB 上执行 texture resize + WebP transcode并替换 staged
主 GLB 与 staged manifest不触碰已发布的 `package/`,也不重写 preview HTML。未压缩版本只在
`<areaDir>/_pipeline/compress-*` 临时目录中存在,成功或失败后都会清理。
`scripts/compress-glb.js` 是该阶段调用的低层脚本,也可单独运行做实验。
@@ -62,20 +63,17 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
### 3. Contracts
- 输入必须是现有 `.glb` 文件;`--output` 必须不同于 `--input`
- 输出是并列压缩 GLB默认构建产物不被替换
- `compress` 阶段依赖默认 `glb``metadata` `cesiumPreview` 已存在。
- 低层脚本仍输出到与输入不同的路径;`build-area.js` 使用临时输入/输出路径,避免原地压缩
- `compress` 阶段依赖 staged `glb``metadata` 已存在,并在全部临时交付文件生成和校验后
`renameSync` 替换它们。
- 默认链固定为 `gltf-transform resize -> gltf-transform webp`
- `compress` 阶段默认 `textureSize=768`;低层脚本单独运行时默认 `--texture-size 1024`
- `build-area.js` 默认写:
- `<fileStem>-compressed-webp768.glb`
- `<fileStem>-compressed-webp768.json`
- `<fileStem>-compressed-webp768-cesium-preview.html`
- 伴生 metadata 的 `asset``id="main"` 资产 URL 改为压缩 GLB 文件名;其余资产(包括
`category="semantic"` 的 Cesium 分类检查 GLB必须原样保留。
- preview HTML 只替换 `window.OSM_ASSET_PREVIEW_CONFIG``glbName` /
`metadataName` 和 loading 文案,不改 preview runtime。
- `build-area.js` 不会写并列 `-compressed-*` 交付物;`package` 是唯一能发布静态文件到
`package/` 的阶段。
- staged manifest 的 `assets[*].uri` 必须保持 package-relative`models/<area>.glb`;分类
layer GLB 必须原样保留。
- 成功时 stdout 打印 `GLB_COMPRESS_DONE <json>`包含压缩前后大小、image /
non-image bytes、结构计数、扩展metadata / preview 输出路径。
non-image bytes、结构计数、扩展metadata 输出路径。
### 4. Validation & Error Matrix
@@ -86,27 +84,24 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
| `--output` 等于 `--input` | 抛错,避免覆盖源 GLB |
| 数值参数超范围 | 抛错并指出合法范围 |
| `gltf-transform` 退出非零 | 抛错并带上 status / signal |
| `--preview` 没有 `--metadata` | 抛错,因为 preview 必须指向存在的 metadata |
| preview HTML 找不到配置块 | 抛错,不做猜测替换 |
| `--stages compress` 但默认 preview 不存在 | `Cesium preview not found: <path>` |
| `--stages compress` 但 staged manifest 不存在 | `Cesium metadata not found: <path>` |
### 5. Good/Base/Bad Cases
- Good: `--stages cesium,compress` 先重导默认 GLB再生成并列压缩产物
- Good: `--texture-size 768 --metadata --preview` 生成压缩 GLB、metadata、HTML
源 GLB 保持不变。
- Good: 默认构建先导出 Cesium再压缩 staged 资产,最后由 `package` 发布
- Good: `--stages compress` 对已有 staged 交付物重新压缩,临时源 GLB 不会留在输出目录。
- Base: 只传 `--input --output` 生成压缩 GLB不生成伴生文件。
- Bad: 使用 `--meshopt` 后没有做 Cesium 兼容性验证就当默认产物发布。
### 6. Tests Required
- `node --check scripts/compress-glb.js`
- `npm run test:compress-glb`:断言压缩 metadata 只替换主资产,不丢失语义资产
- `npm run test:compress-glb`:断言压缩 manifest 只替换主资产,不丢失语义 layer
- `node --check scripts/build-area.js`
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata --preview`
- 对目标区域跑一次 `npm run build:area -- --stages compress`
- `node scripts/glb-digest.js <compressed.glb>` 确认可解析结构和扩展
- 浏览器/Cesium 预览压缩 HTML确认 `EXT_texture_webp` 在目标环境可加载
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata`
- 对目标区域跑一次默认 `npm run build:area`
- `node scripts/glb-digest.js <area>.glb` 确认可解析结构和扩展
- 浏览器/Cesium 预览标准 HTML确认 `EXT_texture_webp` 在目标环境可加载
### 7. Wrong vs Correct
@@ -116,10 +111,110 @@ Wrong:
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a.glb
```
Correct:
Correct for the low-level script:
```bash
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed-webp768.glb
npm run compress:glb -- --input outputs/a/a.glb --output /tmp/a-compressed.glb
```
---
## 可复用资产包发布
### 1. Scope / Trigger
`package` 将已验证的 static staging 提升为下游可消费的唯一交付边界:
`<areaDir>/package/`。它不复制大文件到第二个发布目录;成功时将整个 staging 目录原子 rename
为 package。车辆、路线和动态信号属于预览能力必须留在 `<areaDir>/_preview/`,不能进入 package。
### 2. Signatures
```bash
npm run build:area -- --config config/areas/<area>.json --stages cesium,compress,package
npm run build:area -- --config config/areas/<area>.json --stages preview
node scripts/test-package-contract.js
node scripts/test-package-examples.js
```
下游入口永远是:
```text
outputs/<area>/package/manifest.json
```
### 3. Contracts
发布 manifest 使用 `schema: "osm-asset-package/v1"`,至少包含:
```json
{
"schema": "osm-asset-package/v1",
"packageVersion": "1.0.0",
"areaId": "example",
"coordinateSystem": { "axes": "ENU", "units": "meters", "x": "east", "y": "north", "z": "up" },
"placement": { "longitude": 114.3, "latitude": 30.5, "height": 0, "headingCorrectionDegrees": -90 },
"bounds": { "minLon": 114.2, "minLat": 30.4, "maxLon": 114.4, "maxLat": 30.6 },
"assets": [{
"id": "main",
"role": "scene",
"category": "scene",
"uri": "models/example.glb",
"defaultLoad": true,
"integrity": { "bytes": 123, "sha256": "..." }
}]
}
```
- `coordinateSystem` 固定为 ENU meters`placement` 是 WGS84 锚点与 heading correction。
- 必须恰有一个 `role="scene"` / `category="scene"` / `defaultLoad=true` 主资产roads、buildings、
vegetation、water 是 `role="layer"``defaultLoad=false` 的可选语义层。
- `assets[*].uri``runtime[*].uri` 只能是包内正向相对路径,禁止绝对路径、反斜杠和 `..`
- `runtime` 声明可编程运行时资产;当前至少包含 `traffic-signals` 锚点 JSON、
`traffic-signals-dynamic` 灯珠 GLB以及两个 `traffic-signals-countdown-*` 倒计时 GLB。
它们是资产包的一部分,不属于 `_preview/`
- `package` 在晋升前调用 `validateManifest()`;再为每个模型写 bytes 与 SHA-256然后复验。
- `package` 是唯一拥有最终 package file records 的 stage。`cesium` / `compress` 只能拥有 staging
或计算摘要,不能留下指向最终 package 文件的 outputs record。
- 下游代码和 preview 都从 manifest URL 解析 `assets[*].uri`。参考
`examples/cesium-asset-package.js``examples/three-asset-package.js`
### 4. Validation & Error Matrix
| 条件 | 结果 |
|---|---|
| staging manifest 缺失或无效 JSON | `package` 失败,不替换上次成功 package |
| URI 是绝对路径、含 `..``\\` | `validateManifest()` 失败 |
| ENU / WGS84 字段缺失或越界 | `validateManifest()` 失败 |
| 没有或多于一个 scene asset | `validateManifest()` 失败 |
| manifest 声明的模型文件缺失 | `validateManifest()` 失败 |
| rename 发布异常 | 尝试恢复 `<package>.previous`,保留原 package |
### 5. Good/Base/Bad Cases
- Good: 下游只带走 `package/`,按 manifest 的 WGS84 placement 放置主模型,按需加载 layer。
- Base: 只有 main scene 的区域仍是合法 package没有几何的语义类别不写空 GLB。
- Bad: 将 `_preview/` 的车辆模型或路线加进 package它们是本项目的验证运行时信号灯 runtime 则必须发布。
### 6. Tests Required
- `npm run test:package-contract`schema、URI、placement、唯一 scene 和模型存在性。
- `npm run test:package-examples`Cesium / Three.js resolver 都相对 manifest 解析 URI。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress,package,preview`
- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
-`package/` 复制到其他目录后,重新运行 manifest validator所有 `assets[*].uri` 必须仍能解析。
### 7. Wrong vs Correct
Wrong:
```js
const modelUrl = asset.uri; // Relative to preview HTML by accident.
```
Correct:
```js
const modelUrl = new URL(asset.uri, new URL(manifestUrl, window.location.href)).href;
```
---
@@ -272,6 +367,53 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
```
## Native 道路标线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
### 2. 图层契约
| 文件 | 语义 | 必需 provenance | Blender material layer |
|---|---|---|---|
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
方向箭头必须带 `road_id``lane_id``osm_way_ids``direction``lane_index`
`sequence``distance_along_lane_meters``placement_interval_meters`。转向箭头
必须带 `maneuver``placement_distance_meters`。两者不能共用 provenance 或假装为
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
### 3. 放置与错误矩阵
| 条件 | 结果 |
|---|---|
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
### 4. 必需测试
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距以及无标签道路
不生成路口转向箭头。
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
`direction_arrows``turn_arrows`,且不运行 `package`
### 5. 错误与正确写法
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。
## 斑马线与停止线来源
### 1. 范围与触发条件
@@ -343,13 +485,14 @@ out.vehicleStopLines = crosswalkData.stopLines;
// 原生 lane_markings 停止线不得复制到输出。
```
## 信号锚点的跨阶段消费
## 可编辑信号设施与运行时锚点的跨阶段消费
### 1. Scope / Trigger
路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用
`<geojsonDir>/traffic_signals.json`。它是附属 intermediates 产物,而不是第十个
osm2streets/QGIS 图层。
`<geojsonDir>/traffic_signal_assemblies.geojson` 是 GeoPackage/QGIS 中的附属可编辑点图层;
`<geojsonDir>/traffic_signals.json` 是从它严格校验并派生的运行时产物。前者不属于九个
`SCENE_LAYERS`,后者不进入 GeoPackage。
### 2. Signatures
@@ -366,15 +509,19 @@ area.outputs.trafficSignals
### 3. Contracts
- `build-area.js:writeTrafficSignals()` 是锚点 JSON 的生产者,调用
`traffic-signals.js:readTrafficSignals()`,输入为 `vehicle_stop_lines.geojson`
`intersection_surface.geojson`
- `intermediates``reimport` 都必须在其 GeoJSON 产物稳定后重写锚点,确保 QGIS
人工修补反导入后Blender 和 preview 仍使用同一事实
- `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_signals.json` 不得加入 `SCENE_LAYERS`GeoPackageQGIS 工程;这些层只能
继续包含九个道路场景图层
- `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` 必须相等,表示横杆与灯壳的
@@ -385,9 +532,9 @@ area.outputs.trafficSignals
| 条件 | 结果 |
|---|---|
| `intermediates``reimport` 有合法停止线和路口面 | 写出 `version``signals` 数组,即使数组为空 |
| `intermediates``reimport` 有合法编辑层 | 写出 `version``signals` 数组,即使数组为空 |
| 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` |
| 单个停止线无法可靠关联路口 | 锚点生成器跳过该项,其他进口照常输出 |
| `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 |
| 用户仅修改 QGIS 后运行 `reimport` | 重新生成锚点,不沿用旧坐标 |
### 5. Good/Base/Bad Cases
@@ -602,7 +749,7 @@ const gate = classifyAreaQuality(result);
### 1. Scope / Trigger
Stage manifest 是区域构建阶段或独立验证通过后的机器可读产物契约。它覆盖预检记录与完整区域链:
`preflight``intermediates``reimport``blender``cesium``preview``compress`
`preflight``intermediates``reimport``blender``cesium``compress``package``preview`
它用于诊断产物是否存在、是否 stale、体量是否超预算以及后续 `check:area` /
增量构建判断。
@@ -624,6 +771,7 @@ Manifest 路径固定:
<areaDir>/_pipeline/stages/cesium.manifest.json
<areaDir>/_pipeline/stages/preview.manifest.json
<areaDir>/_pipeline/stages/compress.manifest.json
<areaDir>/_pipeline/stages/package.manifest.json
```
代码入口:
@@ -716,9 +864,18 @@ File records use this shape:
- `summary.glb.extensionsUsed`
- `summary.budget`effective limits、usage 和 violationswarnings 来自同一个预算评估
`cesium` 会调用 preview 生成函数,但 preview HTML / route / vehicle model 的 freshness
所有权属于独立 `preview` manifest。否则单跑 `--stages preview` 会把 Cesium manifest
错误判 stale。
`cesium` 只拥有 staging 内的静态模型与 manifest完成后必须依次由 `compress``package`
交付。preview HTML / route / vehicle model 的 freshness 所有权属于独立 `preview` manifest
`compress` manifest 不记录最终文件 records它们位于可被 `package` rename 的 staging 目录。
`package` manifest 是唯一记录 `package/manifest.json`、主 GLB 与 package 目录的 stage manifest
避免发布后 compress manifest 因路径迁移而立刻 stale。
`package` manifest:
- `inputs.stagingManifest`:发布后的同一 manifest 文件记录
- `outputs.packageDir``outputs.manifest``outputs.primaryGlb`
- `summary.assets``summary.packageDir`
### Preview Assembly Boundary
@@ -741,6 +898,9 @@ runtime 与 HTML并写 preview manifest。预览内容实现不得回流到
- `inputs.osm`
- `inputs.glb`
- `inputs.metadata`
- `inputs.lanePolygons`
- `inputs.network`
- `inputs.intersectionSurface`
- `inputs.previewCss`
- `inputs.previewJs`
- `outputs.cesiumPreview`
@@ -750,14 +910,11 @@ runtime 与 HTML并写 preview manifest。预览内容实现不得回流到
`compress` manifest:
- `inputs.glb`
- `inputs.metadata`
- `inputs.cesiumPreview`
- `outputs.compressedGlb`
- `outputs.compressedMetadata`
- `outputs.compressedCesiumPreview`
- `outputs.glb`
- `outputs.metadata`
- `outputs.cesiumPreview`
- `summary.sourceGlb`
- `summary.compressedGlb`
- `summary.glb`
- `summary.options`
- `summary.compressionRatio`
- `summary.savedBytes`
@@ -783,8 +940,8 @@ Manifest files are written atomically via `*.tmp` then `renameSync`.
- Good: `--stages intermediates` 成功后写 `intermediates.manifest.json`,诊断显示
`ok intermediates`
- Good: `--stages blender` 成功后写 `blender.manifest.json`,诊断显示 `ok blender`
- Good: `--stages cesium` 成功后写 `cesium.manifest.json``preview.manifest.json`
- Good: `--stages preview` 只更新 preview manifest不让 cesium manifest stale。
- Good: `--stages cesium,compress,package,preview` 成功后依序写四份各自拥有的 manifest。
- Good: `--stages preview` 只更新 preview manifest不让 package manifest stale。
- Good: `--stages compress` 成功后写 `compress.manifest.json`summary 记录压缩比和节省字节。
- Base: 旧产物没有当前 ownership 路径的 manifest诊断显示 expected manifest missing
提示重跑对应阶段。
@@ -803,6 +960,7 @@ Manifest files are written atomically via `*.tmp` then `renameSync`.
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates`
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender`
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium`
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress,package,preview`
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview`
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress`
- `npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
@@ -921,15 +1079,16 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/``.gpkg``.qgz``-preview.png` |
| `reimport` | GeoPackage → GeoJSON**反向** | `.gpkg` | `osm2streets_web_out/` |
| `blender` | OSM + GeoJSON → 场景 | `.osm``osm2streets_web_out/` | `.blend``.png` |
| `cesium` | 场景 → GLB + 元数据 + 预览页 | `.blend` | `.glb``.json`、预览 HTML 及其静态资源 |
| `preview` | 只补生成预览页 | `.glb``.json` | 预览 HTML 及其静态资源 |
| `compress` | 生成并列压缩 Cesium 产物 | `.glb``.json`、默认预览 HTML | `-compressed-webp*.glb/json/html` |
| `cesium` | 场景 → static staging GLB + manifest | `.blend` | `_pipeline/package-staging/models/*.glb`、staged manifest动态 GLB 到 `_preview/` |
| `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest |
| `package` | 校验并原子发布静态资产包 | staging manifest 与 models | `package/manifest.json``package/models/*.glb` |
| `preview` | 用已发布 manifest 生成验证预览 | `package/manifest.json`、动态输入 | 预览 HTML、静态 runtime、`_preview/` 动态资源 |
调度是顶层的阶段 `if``build-area.js` 开头),顺序固定,**阶段之间不传内存状态,
只通过磁盘产物耦合**。这就是单跑某个阶段能work 的原因。
`cesium` 阶段结束时会直接调 `writeCesiumPreview(area)``build-area.js:285`),所以
`preview` 只在"已有 GLB、只想重生成 HTML"时才需要单独跑
完整构建中 `preview``package` 后运行。单跑 `preview` 用于已发布 package 但想重生成
验证 UI 或 `_preview/` 动态资源的情况
### 别名
@@ -944,11 +1103,11 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
```js
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160
all: ["intermediates", "blender", "cesium"],
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
```
`preview` 同样不在 `all` 里——`cesium` 已经包含它。`compress` 也不`all` 里——
它生成的是替代压缩产物,不是 baseline GLB
`compress``package``preview` `all` 中,确保完整构建以压缩后的可复用静态 package
以及可运行的验证预览交付
### `intermediates` 与 `reimport` 互斥
@@ -958,9 +1117,8 @@ all: ["intermediates", "blender", "cesium"],
这是**显式拒绝而不是警告**——两者同时开,无论谁先跑,另一个的工作都白做。
`normalizeAreaConfig``stages.reimport``stages.preview` `stages.compress` 硬编码为 `false`
**不能从配置文件打开**,只能靠 `--stages` 显式请求
恢复动作、补丁动作和替代产物动作都不该被一份配置文件变成默认行为。
`normalizeAreaConfig`只有 `stages.reimport` 硬编码为 `false`,只能靠 `--stages` 显式请求;
`stages.compress``stages.package` `stages.preview` 为默认 `true`
---
@@ -997,7 +1155,7 @@ parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SC
| 在阶段函数里现拼输出路径 | 路径规则出现第二份定义 |
| 低层脚本直接读 `config/areas/*.json` | 打破两层配置边界 |
| 布尔配置用 `\|\|` 兜底 | `false` 被翻转成默认值 |
| 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 |
| 让 `reimport` 能从配置文件默认开启 | 恢复动作变成常规行为 |
| 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 |
| 改 stage 的 stdout 标记 | 静默破坏 parity 契约 |
| 顺手把多份 `parseArgs` 合并 | 扩大 diff且独立入口的独立性是刻意的 |

View File

@@ -120,6 +120,56 @@ SCENE_LAYERS.forEach((layer, index) => {
## Blender 调用
### macOS Blender 4.5 的 Metal 启动兼容
#### 1. Scope / Trigger
`export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。
#### 2. Signatures
Cesium 阶段的调用参数必须包含:
```text
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ...
```
#### 3. Contracts
`--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。
#### 4. Validation & Error Matrix
| 情况 | 结果 |
|---|---|
| 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 |
| 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 |
#### 5. Good / Base / Bad Cases
- Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。
- Base: Blender 场景阶段未受影响时,不额外改变其启动参数。
- Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。
#### 6. Tests Required
- `npm run test:build-stages` 断言导出参数仍包含 workaround。
- 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。
#### 7. Wrong vs Correct
Wrong:
```text
--background --python blender/export_cesium.py
```
Correct:
```text
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py
```
### 两种调用姿势
| 阶段 | 参数 | 出处 |

View File

@@ -51,17 +51,21 @@ config/areas/<id>.json
│ → _pipeline/stages/blender.manifest.json
├─[cesium]────────▶ Blender + blender/export_cesium.py
│ 读 .blend → <id>.glb + <id>.json
→ 并自动执行 preview
│ 读 .blend → _pipeline/package-staging/models/<id>.glb
+ staged manifest动态预览 GLB 写入 _preview/
│ → _pipeline/stages/cesium.manifest.json
├─[preview]───────▶ 生成 <id>-cesium-preview.html
+ 拷贝 lib/cesium-preview.{js,css}
│ + 车辆巡航路线与模型
│ → _pipeline/stages/preview.manifest.json
├─[compress]──────▶ 压缩 staging 内主 GLB 并更新 staged manifest
→ _pipeline/stages/compress.manifest.json
─[compress]──────▶ 生成并列压缩 GLB / metadata / preview
_pipeline/stages/compress.manifest.json
─[package]───────▶ 校验 manifest 与全部静态模型,原子发布 package/
package/manifest.json + package/models/*.glb
│ → _pipeline/stages/package.manifest.json
└─[preview]───────▶ 生成 <id>-cesium-preview.html
│ + 拷贝 lib/cesium-preview.{js,css}
+ _preview/ 车辆巡航路线、模型、动态信号
│ → _pipeline/stages/preview.manifest.json
```
**阶段之间只通过磁盘产物耦合**不传内存状态。这是单跑任意阶段能work 的前提。
@@ -87,7 +91,7 @@ config/areas/<id>.json
| 文件 | 行数 | 职责 |
|---|---|---|
| `build-area.js` | 约 530 | 主入口区域配置读取、阶段调度、preview 文件写入和 stage manifest ownership |
| `build-area.js` | 主入口:区域配置读取、阶段调度、package 发布、preview 文件写入和 stage manifest ownership |
| `diagnose-area.js` | 36 | 快速诊断入口:调用共享 area diagnostics 并打印完整报告 |
| `check-area.js` | 74 | 区域质量门入口:调用共享 area diagnostics输出 PASS/FAIL 并设置退出码 |
| `lib/area-diagnostics.js` | 776 | 共享区域诊断事实源OSM、产物、metadata、stage manifest、GLB digest 和质量门分类 |
@@ -99,6 +103,7 @@ config/areas/<id>.json
| `lib/vehicle-route.js` | 约 180 | 从 OSM 提取确定性预览巡航路线 |
| `lib/vehicle-model.js` | 约 150 | 生成内嵌 buffer 的预览车辆 glTF |
| `lib/area-preview.js` | 约 110 | 复制 preview runtime、生成 HTML 与转义配置注入 |
| `lib/package-contract.js` | package manifest 校验、相对 URI 与 SHA-256 完整性记录 |
| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) |
| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) |
| `parity.js` | 270 | 产物一致性校验驱动 |

View File

@@ -8,8 +8,8 @@
## 定位
预览层是**验证性的,不是产物本身**。它加载 `cesium` 阶段导出的 `.glb` + `.json`
用来确认资产在真实 Cesium 里的样子。改这一层**不会**改变 Blender/GLB 资产。
预览层是**验证性的,不是产物本身**。它加载 `package/manifest.json`,用来确认已发布资产
在真实 Cesium 里的样子。改这一层**不会**改变 package 内的 Blender/GLB 静态资产。
车辆巡航同理——README 里写明它是"用于验证高精度巡航可用性的预览层功能"。
@@ -53,9 +53,14 @@ const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4
**加一个新的可配置项**`cesiumPreviewHtml()` 里加进注入的 JSONJS 侧从 `config` 读,
两边都要动。
`build-area.js` 只保留 GLB / metadata 依赖检查、写入顺序和 preview manifest ownership
`glbName``metadataName` 都是 `package/manifest.json`。浏览器读取 package manifest 后,
每个 `assets[*].uri` 必须相对**manifest 文件**解析,绝不能相对 preview HTML 解析;否则将
错误请求 `outputs/<area>/models/...` 而不是 `outputs/<area>/package/models/...`
`build-area.js` 只保留已发布 package / 动态输入的依赖检查、写入顺序和 preview manifest ownership
不要把 HTML 模板、runtime copy 或转义实现移回阶段调度器。路线 JSON 与车辆 glTF 分别由
`vehicle-route.js``vehicle-model.js` 生成,二者都是不启动外部工具的 Node 模块。
交通信号灯 runtime 则从 package manifest 的 `runtime` 读取;预览只负责驱动其状态,不拥有这些文件。
---
@@ -112,6 +117,9 @@ setLoadingMessage("Preparing view")
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
```
预览加载的生成式 JSON路线和交通信号使用 `fetch(..., { cache: "no-store" })`,因为
这些文件保持稳定文件名但会被单独重生成;浏览器不得继续显示旧的巡航路线。
调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。
---
@@ -182,6 +190,21 @@ let baseStatus = "";
这会让用户误判材质和模型质量。若确实需要性能模式,应做成显式开关,而不是默认牺牲
预览清晰度。
### 车辆事件仅属于预览会话
点击车辆的信息卡可在 `normal``breakdown``accident` 三态间切换。这是验证交互,
不得写入 `package/`、路线 JSON 或 OSM。状态必须附着在 `addCruiseVehicle()` 返回的记录上;
`createTrafficAwarePositions()` 只在状态为 `normal` 时推进已有的 route distance恢复正常从
当前停点继续。
- `breakdown`:黄色扳手 label路线保留原色。
- `accident`:红色警示 label路线设为红色。
- `normal`:隐藏 label恢复原路线颜色。
车辆实体以 `properties.vehicleId` 标识;点击拾取必须只处理此属性,不能把静态模型、路线或
信号灯当作车辆。Cesium InfoBox 在本预览中关闭,信息卡必须使用 HTML/CSS并把新增 DOM 句柄
集中在 `cesium-preview.js` 顶部。
### 单资产 vs 多资产的开关
```js

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

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Check command ownership, disk artifact boundaries, and legacy pipeline compatibility."}
{"file":".trellis/spec/preview/index.md","reason":"Check browser workbench state, error handling, and no-build browser constraints."}
{"file":".trellis/spec/config/index.md","reason":"Check new area output/config normalization and compatibility."}

View File

@@ -0,0 +1,110 @@
# Native Road Compiler Workbench Design
## Architecture
The native compiler is an additive pipeline path. It owns a separate output
directory under an area and never writes into `osm2streets_web_out/`.
```text
OSM XML + native-road-overrides.json
|
v
Canonical Road Model
roads / endpoints / junction candidates / provenance
|
v
Native Geometry Compiler
road surfaces / initial junction surfaces / diagnostics
|
+--> native-road/compiled.json
+--> native-road/layers/*.geojson
+--> native-road/diagnostics.json
+--> native-road/comparison.json
|
v
Road Workbench HTTP service
browser map + inspect/edit/save API
```
The canonical model is the authority. Render layers, browser display data, and
future Blender compatibility adapters are derived from it.
## Commands And Ownership
- `npm run road:compile -- --config <area-config>` performs no browser work.
It reads OSM plus the persisted override file, writes a staged native-road
result, validates it, and atomically promotes the result directory.
- `npm run road:workbench -- --config <area-config>` compiles first unless
`--no-compile` is supplied, then starts a local HTTP server scoped to that
one area.
- The server exposes read-only compiler artifacts and one explicit save API
for validated overrides. It does not expose arbitrary filesystem paths.
- Existing `build:area`, `intermediates`, QGIS, Blender, Cesium, and package
paths remain unchanged in the first iteration.
## Data Contracts
### Canonical road model
Each road direction carries a stable ID derived from OSM identifiers, source
way IDs, endpoint node IDs, centerline, explicit/inferred attributes, applied
override IDs, and diagnostics. Junction candidates likewise use their OSM node
ID when available. Values include provenance such as `tag:lanes:forward`,
`inferred:highway-default`, or `override:<id>`.
Each `Movement` is a stable semantic record joining a connection, source and
target road/lane, turn class, provenance, and an optional connector geometry.
`geometryStatus="connector"` publishes a connector curve;
`geometryStatus="continuous"` means the lane centerlines meet at the node and
does not invent a zero-length curve; `deferred-too-long` retains the movement
while withholding unsafe geometry.
### Override file
`<area>/native-road-overrides.json` is versioned and human-reviewable. It
contains an array of uniquely identified changes whose targets are stable road
or endpoint IDs. Supported v1 records are `road` parameter overrides and
`junction-connection` decisions. The save endpoint validates schema, target
existence, finite values, and duplicate/conflicting edits before atomic write.
### Compiler artifacts
`<area>/native-road/compiled.json` is the workbench's single read model and
contains the canonical `movements` list.
`layers/` contains generated GeoJSON with source/provenance properties.
`diagnostics.json` contains severity, stable subject ID, source IDs, rule,
message, and optional geometry. `comparison.json` reports counts and coverage
against available osm2streets layers; it does not claim quality solely from
visual differences.
## Browser Workbench
The browser uses OpenLayers as its sole GIS runtime, served directly from the
local allowlisted `node_modules` packages with a browser import map. The map
renders fit-to-data OSM centerlines, native surfaces, optional osm2streets
reference layers, diagnostics, selected-object provenance, movements, and
overrides. This provides mature map selection and hit detection without a
framework or bundler.
The user can select a road or endpoint, edit only v1 fields, inspect the
resulting override record, explicitly save it, and recompile/reload. Saved
state is visibly differentiated from unsaved state. The workbench must not
offer freehand final-polygon editing, since that would break reproducibility.
## Geometry And Validation
V1 produces road segments from projected centerline offsets and terminal
cross-sections. It only generates a junction surface when endpoints satisfy
the supported ordinary T/cross shape and geometry checks; otherwise it emits a
diagnostic rather than inventing an invalid polygon. Validation detects
dangling endpoints, unclosed/self-intersecting rings, non-finite coordinates,
unsupported multi-level intersections, and source/topology ambiguity. Small
numerical cleanup may be explicit and recorded; semantic failures are never
silently repaired.
## Compatibility And Rollout
The first compiler's layers use existing render-layer names where meaningful,
but are stored separately. A later, explicitly enabled Blender provider option
may consume native layers after comparison gates pass. Delete/replace behavior
is out of scope; rollback is selecting the existing osm2streets pipeline.

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Native compiler commands, artifacts, and area config extend the Node pipeline while preserving legacy stages."}
{"file":".trellis/spec/preview/index.md","reason":"The browser workbench is a new DOM runtime and must follow local preview loading and state conventions where applicable."}
{"file":".trellis/spec/config/index.md","reason":"New native-road output paths and config behavior extend the normalized area contract."}

View File

@@ -0,0 +1,32 @@
# Implementation Plan
1. Add area output/config normalization and command entrypoints for the native
compiler, preserving existing stage behavior and paths.
2. Implement a shared OSM road parser and canonical road/endpoints model with
source provenance, explicit versus inferred properties, and stable IDs.
3. Implement v1 override schema, validation, load/apply behavior, atomic save,
and focused unit tests.
4. Implement projected road segment geometry, supported T/cross junction
detection, native GeoJSON artifact emission, diagnostics, and comparison
summary.
5. Implement a dependency-free local workbench server and browser UI with
selection, provenance display, v1 parameter/topology editing, explicit save,
compile/reload, and error states.
6. Add native compiler tests using focused fixtures plus nantaizi analysis;
run existing relevant Node tests to confirm legacy behavior remains intact.
7. Compare nantaizi and at least one supplied problematic OSM sample. Record
metrics, unsupported cases, and follow-up work in task research.
## Validation
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run test:build-stages
npm run test:preflight
npm run test:preview-assets
```
Browser validation includes loading the workbench, editing a road parameter,
saving, verifying the override file, recompiling, reloading, and confirming
provenance identifies the saved override.

View File

@@ -0,0 +1,93 @@
# Native road compiler workbench
## Goal
Build an incremental native road compiler for Chinese urban and campus OSM
data that can progressively exceed osm2streets in geometry quality,
explainability, and repeatable correction. The existing osm2streets pipeline
must remain usable while the native compiler is developed and compared.
The first deliverable is a browser-based Road Workbench. It must expose the
native compiler's source data, generated geometry, and diagnostics, allow
users to make small semantic/topology corrections, persist those corrections
as versionable overrides, and reload them automatically in later runs.
## Confirmed Facts
- Nantaizi currently works relatively well because its OSM data received
deliberate supplemental tagging; it still has missing boundaries and
polygons that cannot be closed.
- Other tested OSM inputs expose osm2streets sensitivity to input structure
and leave too much opaque, final-polygon repair work in QGIS.
- Existing Blender consumes the nine GeoJSON render layers from
`osm2streets_web_out/`; QGIS GeoPackage edits can currently be reimported
only as a whole batch.
- The repository has no existing interactive browser editing service. Existing
Cesium preview is a static, generated verification page.
## Requirements
- R1: Add a native-road-compiler path without replacing or regressing the
existing osm2streets path.
- R2: Parse OSM into a canonical, source-traceable road model with stable
references to OSM ways and nodes, explicit values versus inferred values,
and diagnostics.
- R3: Compile at least ordinary road segments and the initial supported
junction subset into the existing render-layer contract, allowing existing
Blender/Cesium consumers to be reused.
- R4: Provide a browser Road Workbench that overlays raw OSM topology,
generated geometry, osm2streets comparison geometry when available, and
compiler diagnostics.
- R5: The workbench must permit scoped user adjustments and save them to an
area-local, human-reviewable override file. Future compile and workbench
runs must load that file automatically.
- R6: Each generated object and diagnostic must be traceable to OSM source
IDs, compiler rule/inference evidence, and relevant override IDs.
- R7: Validate topology and geometry before publishing generated layers;
report unresolved semantic errors instead of silently disguising them as
geometric repair.
- R8: Develop against nantaizi plus problem inputs and report native versus
osm2streets comparison metrics.
## Scope Boundaries
- First implementation targets Chinese urban/campus roads, ordinary road
segments, T/cross junctions, directed/multi-lane roads, and data already
tagged in nantaizi where possible.
- Existing Blender, Cesium export, package format, building, vegetation, and
water generators are out of scope unless a compatibility adapter requires a
narrowly scoped change.
- Directly editing final render polygons is not the intended correction model;
generated layers remain derived output.
- Complex interchanges, arbitrary multilayer junctions, and full worldwide OSM
coverage are deferred until driven by concrete samples.
## Acceptance Criteria
- [ ] A native compile command produces a canonical road model, generated
layers, diagnostics, and comparison artifacts for a configured area without
changing the osm2streets output path.
- [ ] A browser command serves a Road Workbench for an area and clearly shows
source topology, generated output, diagnostics, provenance, and saved
overrides.
- [ ] A user can make the agreed first-scope override edits in the browser,
save them explicitly, and receive a durable area-local override artifact.
- [ ] Re-running compile or reopening the workbench applies saved overrides
automatically and exposes their provenance.
- [ ] The compiler reports invalid/unclosed geometry, dangling road ends,
and unresolved junction/lane ambiguity with source IDs.
- [ ] Nantaizi and at least one known problematic area can run through the
native analysis/preview path, with comparison metrics captured rather than
a claim based only on visual inspection.
## Key Decisions
- The first browser editing surface supports road parameters (width, directed
lane counts, left/right sidewalk state) plus junction endpoint
connect/disconnect decisions.
- Turn restrictions, stop lines, and crosswalk placement are deferred until
the compiler has a validated road/junction editing loop.
- Overrides are a versioned, human-reviewable JSON artifact owned by the area,
not edits to generated polygon layers.
- Native output and osm2streets output remain parallel during development;
neither silently overwrites the other.

View File

@@ -0,0 +1,50 @@
# Two-Area Native Road Comparison
## Runs
2026-08-14:
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/hanyang-block.json --stages intermediates
npm run road:compile -- --config config/areas/hanyang-block.json
```
`comparison.json` is a coverage and diagnostic record. Feature counts are not
a geometry-quality score: osm2streets and the native compiler segment roads at
different levels.
| Area | Directional roads | Native surfaces | Native junctions | Movements | Internal ends | Manual candidates | osm2streets road surfaces |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| nantaizi-lake-innovation-valley | 34 | 19 | 6 | 46 | 4 | 2 | 50 |
| hanyang-block | 475 | 352 | 20 | 369 | 161 | 83 | 1826 |
## Observed Failure Modes
The hanyang osm2streets/QGIS run completed, but its log reported repeated:
- roads trimmed into oblivion;
- degenerate intersections that could not be collapsed because layers, names,
highway types, or lane specifications differ;
- intersection polygon requests with no roads.
The native compiler did not hide the related uncertainty. It reported 161
internal road ends. Eighty-three have one or more nearby, direction-compatible
candidate departures within 35 metres; these remain suggestions for explicit
review rather than automatic topology edits. The initial five one-way junctions
that lacked connector curves now publish road-surface envelopes based on their
semantic movements. Across hanyang, 369 movements are identified: 243 require
a connector curve, while 126 are continuous at their OSM node and intentionally
have no separate geometry.
## Resulting Priorities
1. Keep manual candidate suggestions and semantic overrides as the correction
path for near-miss topology. Do not bulk-connect candidates.
2. Expand ordinary junction support from 3/4 physical approaches only after
identifying a repeated unsupported topology; one-way movements that are
continuous at a node are already supported without fake connector geometry.
3. Add an inspectable movement artifact so turn geometry is not the only
representation of a road-to-road movement.
4. Use a visual review of a few explicit hanyang diagnostics before changing
connection-distance or road-class rules.

View File

@@ -0,0 +1,28 @@
{
"id": "native-road-compiler",
"name": "native-road-compiler",
"title": "Native road compiler workbench",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-13",
"completedAt": null,
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"08-14-native-road-lane-markings"
],
"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

@@ -0,0 +1,4 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Verify package stage ordering, manifest freshness, and external-tool boundaries."}
{"file":".trellis/spec/config/index.md","reason":"Verify all new output paths remain centralized in area-config."}
{"file":".trellis/spec/preview/index.md","reason":"Verify preview remains a consumer of package assets, not part of the published package."}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Assess intentional output-contract changes and asset verification scope."}

View File

@@ -0,0 +1,135 @@
# Asset Package Contract Design
## Product Boundary
`outputs/<area-id>/` remains the build workspace. Its `package/` child is the only
publishable subtree and may be copied unchanged to another project. The package has
no dependency on the parent directory, repository checkout, absolute local paths,
QGIS, Blender, or the Cesium preview.
```
outputs/<area-id>/
package/ # publishable boundary
manifest.json # asset-package/v1
models/
<area-id>.glb # compressed complete static scene
roads.glb
buildings.glb
vegetation.glb
water.glb
<area-id>-cesium-preview.html # local verification only
_preview/ # vehicles, routes, dynamic signal runtime
_pipeline/ # staging, manifests and build diagnostics
osm2streets_web_out/, *.gpkg, *.qgz, *.blend, *.png
```
The package does not duplicate the GLBs: Cesium export and compression target the
package model paths. Build staging lives under `_pipeline/` and is removed before a
successful package is published.
## Manifest v1
The JSON root uses `schema: "osm-asset-package/v1"`. Required top-level fields are:
| Field | Contract |
|---|---|
| `schema`, `packageVersion`, `areaId` | Stable identity and schema evolution point |
| `coordinateSystem` | Literal local ENU contract: X east, Y north, Z up, meters |
| `placement` | WGS84 anchor longitude/latitude/height plus heading correction degrees |
| `bounds` | WGS84 `minLon`, `minLat`, `maxLon`, `maxLat` |
| `assets` | Deterministic list of declared publishable assets |
| `sceneStats` | Optional descriptive OSM-derived counts, not loading-critical |
Each asset has `id`, `role`, `category`, `uri`, and `defaultLoad`. `uri` is a
forward-slash relative path inside `package/`; it must not be absolute, start with
`/`, contain `..`, or resolve outside the package. Roles are `scene` for the complete
static scene and `layer` for separately loadable semantic subsets. Categories are
fixed in v1: `scene`, `roads`, `buildings`, `vegetation`, `water`.
`models/<area-id>.glb` is the only `scene` entry and defaults to load. Layer GLBs are
optional alternatives for selective loading and default off; consumers must not load
both the complete scene and overlapping layers unless intentionally composing them.
The manifest deliberately excludes `source_osm`, `source_geojson`, arbitrary Cesium
JavaScript, vehicle routes, vehicle models, dynamic signal states, countdown models,
preview runtime paths, build timestamps and desktop paths. File digests and sizes may
be added under an optional `integrity` object only after they are computed from final
compressed files.
## Pipeline Design
### 1. Static export seed
`blender/export_cesium.py` continues to own static GLB generation, WGS84 bounds,
anchor and semantic collection selection. It must emit data suitable for a manifest
seed, not a preview metadata document. Static traffic-signal geometry already in the
main scene remains part of the static scene; animated traffic signal and countdown
collections are not package assets.
### 2. Package staging and compression
The build orchestrator owns output paths through `normalizeAreaConfig()`. It adds
explicit package paths and exports static GLBs to a package staging directory beneath
`_pipeline/`. Compression works only on staged package files and rewrites the staged
manifest's main GLB URI after successful compression. It never needs to copy or alter
preview HTML.
After path validation, file existence checks, and manifest validation, the package
stage promotes the whole staging directory to `outputs/<area-id>/package/`. Promotion
is directory-level and replaces a previous package only after the new one is valid.
On failure, the previous published package remains usable and staging is retained or
reported for diagnosis according to existing pipeline failure conventions.
### 3. Preview adapter
The existing Cesium preview remains a verification tool outside the package. It reads
the package manifest via its relative path, then loads preview-only route, vehicle and
dynamic-signal descriptors from `_preview/`. Dynamic traffic assets are declared in a
preview-specific descriptor, never appended to package `assets`. This preserves
existing high-precision cruise validation without making it a downstream requirement.
### 4. Stage and compatibility behavior
Canonical order becomes `intermediates/reimport -> blender -> cesium -> compress ->
package -> preview`. `package` is included in the full default build and is callable
explicitly to validate/publish existing staged static exports. `preview` stays an
optional/verification stage, not a package dependency.
Existing `npm run build:area -- --config ... --stages ...` remains supported. Root
level legacy GLB/JSON/HTML files are not deleted by the migration; they are not read
as a fallback by the new package contract. A clean full build produces the package as
the canonical downstream asset source.
## Consumer Examples
Examples live in repository source, outside individual packages:
- Cesium: fetch `manifest.json`, derive the ENU frame from `placement`, apply heading
correction, then load `assets` by `uri`.
- Three.js: load the selected model URL relative to the manifest URL; expose the same
ENU placement object to the host application's georeferencing adapter. It must not
imply that Three.js alone converts ENU to WGS84.
Both examples load the complete scene and demonstrate selecting a single `layer`.
They validate that URLs are resolved relative to the manifest rather than the current
page or repository root.
## Validation and Rollback
Unit tests validate schema fields, category/role legality, package-relative URI rules,
and rejection of omitted/extra files. An integration fixture constructs a minimal
package and verifies that Cesium and Three.js example resolvers choose identical
relative URLs and placement values. A target-area package test checks final compressed
GLB parsing and manifest references.
The package stage writes a stage manifest containing only final published records and
their integrity. Diagnostic checks distinguish missing package, invalid package, and
stale package. Rollback is to the prior `package/` directory; legacy preview outputs
remain untouched throughout rollout.
## Deferred Decisions
- Rich per-feature semantics, routing graph delivery and simulation inputs are not in
v1; they should be introduced through a later schema version only when a downstream
consumer requires them.
- Asset coverage improvements are downstream of this contract work.

View File

@@ -0,0 +1,4 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Stage ordering, manifests, external-command boundary, and compression contracts."}
{"file":".trellis/spec/config/index.md","reason":"Area output-path normalization and configuration contract."}
{"file":".trellis/spec/preview/index.md","reason":"Preview config injection and browser runtime compatibility."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Blender-to-Node-to-browser coordinate and artifact contract risks."}

View File

@@ -0,0 +1,108 @@
# Asset Package Contract Implementation Plan
## Phase 0: Contract Lock-In
- [ ] Add a package-contract module that owns v1 schema constants, allowed roles and
categories, URI containment validation, manifest construction and validation.
- [ ] Add focused unit fixtures for valid manifests and each invalid condition:
missing schema/placement/bounds, malformed coordinates, invalid role/category,
absolute or traversing URI, duplicate asset ID, missing primary scene, and referenced
file outside the package.
- [ ] Document `package/` as the sole downstream boundary in README and pipeline/config
specs; retain a clear distinction between release assets and preview artifacts.
Gate: manifest v1 can be created and structurally validated without invoking Blender,
Cesium or QGIS.
## Phase 1: Package Paths and Static Export
- [ ] Extend `scripts/lib/area-config.js` with paths for `packageDir`, package manifest,
package model directory, primary GLB and semantic layer GLBs; keep all path derivation
in this module.
- [ ] Change `blender/export_cesium.py` metadata output to a static manifest seed:
bounds, ENU placement and static scene/layer declarations only. Remove local source
paths and inline Cesium source code from the published data.
- [ ] Direct static GLB output to package staging paths and retain dynamic signal/countdown
output as preview-only paths outside package staging.
- [ ] Update `build-area.js`, semantic asset checks, stage manifests and diagnostics to
consume the new path contract rather than constructing paths locally.
Gate: a Cesium export produces a valid static manifest seed and every declared static
asset exists under package staging; no preview-only asset appears in it.
## Phase 2: Compression and Atomic Publication
- [ ] Refactor the default compression stage to operate on staged package files,
preserving the final manifest shape while replacing only the primary GLB bytes and
integrity data.
- [ ] Add a `package` stage after `compress`; validate every asset and atomically promote
staging to `outputs/<area-id>/package/`.
- [ ] Update canonical stage ordering, `all`, interactive CLI labels and defaults.
- [ ] Ensure a failed compression/package validation never replaces an existing published
package; record diagnostics sufficient to identify the failed source/staging path.
Gate: a full build creates one publishable package with compressed primary GLB and no
duplicate static GLBs at the area root.
## Phase 3: Preview Adapter and Compatibility
- [ ] Change preview HTML generation to reference `package/manifest.json` relative to
the area output root.
- [ ] Move route/vehicle/dynamic-signal discovery into a preview-only descriptor beneath
`_preview/`, and make `cesium-preview.js` merge it after loading the package manifest.
- [ ] Preserve existing preview controls and graceful behavior when preview-only data is
absent; preview must still render the package static scene.
- [ ] Update `diagnose:area`, `check:area` and stage manifest freshness to distinguish
package artifacts from preview-only files.
Gate: the local Cesium preview loads the published package and continues to show
optional cruise/dynamic preview content without adding either to package manifest.
## Phase 4: Downstream Consumption Proof
- [ ] Add Cesium example code that resolves the manifest URL, validates placement and
loads the full scene or selected layers.
- [ ] Add Three.js example code with a manifest-relative resolver and an explicit ENU
placement handoff to the host's georeferencing integration.
- [ ] Add automated resolver tests so neither example can regress to page-relative,
desktop-absolute or repository-relative URLs.
- [ ] Publish consumer documentation including overlap rules for complete scene vs layers.
Gate: examples run from a copied `package/` directory and require no files outside it.
## Phase 5: Full Verification and Migration
- [ ] Run focused Node tests for package schema, stage resolution, compression metadata,
preview behavior, diagnostics and asset budgets.
- [ ] Run the full default build for the primary area, then `check:area` and package
integrity validation.
- [ ] Inspect the packaged GLB structure and validate the Cesium example in browser;
verify Three.js resolver output with the same manifest.
- [ ] Capture expected intentional output-contract changes in docs/changelog and update
relevant Trellis pipeline/config/preview specs.
- [ ] Preserve legacy root-level artifacts during rollout; document that downstream
consumers must migrate to `package/manifest.json` rather than treating legacy files
as fallback.
## High-Risk Boundaries
- `blender/export_cesium.py` to Node: static metadata fields and semantic asset file
names are a cross-runtime contract.
- Node packaging to preview runtime: preview-only dynamic data must never silently enter
the package manifest.
- Compression to publication: paths must remain package-relative and a partial write must
not replace the last valid package.
- Consumers: ENU axes and heading correction are load-bearing; examples must not create
a second, incompatible georeferencing convention.
## Validation Commands
```bash
npm run test:build-stages
npm run test:compress-glb
npm run test:preview-assets
npm run test:budgets
npm run test:preflight
npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json
node scripts/glb-digest.js outputs/nantaizi-lake-innovation-valley/package/models/nantaizi-lake-innovation-valley.glb
```

View File

@@ -0,0 +1,61 @@
# Reusable asset package contract
## Goal
将当前面向 Cesium 预览的区域输出升级为可由多个下游项目稳定消费的静态资产包。资产包
必须有版本化 manifest、仅使用包内相对引用并明确发布资产与本项目调试/预览产物的边界。
## Confirmed Facts
- `blender/export_cesium.py` 已生成 `<area>.json`,包含主 GLB、roads/buildings/
vegetation/water 分层 GLB、WGS84 anchor、ENU 坐标约定、heading correction 和 OSM bounds。
- 该 JSON 同时包含桌面绝对 `source_osm` / `source_geojson` 路径、Cesium 代码片段,以及
动态信号灯和倒计时预览资产;因此不是可发布的下游契约。
- `outputs/<area>/` 还包含 QGIS、GeoJSON、Blender、Cesium HTML/runtime、车辆巡航和自检文件
它是构建工作目录,不是干净的发布目录。
- 当前默认 Cesium GLB 已在构建末端压缩,且 metadata 中的资产 URL 使用同目录相对文件名。
- 项目目标是生成可复用资产;交通仿真、跟车和信号调度属于下游运行时能力,不属于资产包首版。
## Requirements
- R1定义一个版本化 `manifest.json` v1描述区域 ID、WGS84 anchor、ENU 轴向与 heading
correction、WGS84 bounds、发布资产、语义类别和包内相对路径。
- R2资产包只含可复用的静态交付资产主场景和道路、建筑、植被、水体等分层模型必须有
明确的角色、加载语义和默认行为。
- R3manifest 和包内文件不得包含桌面绝对路径、构建临时目录、Cesium HTML/runtime、
QGIS、GeoJSON、`.blend`、车辆巡航或动态交通信号调度依赖。
- R4在不破坏现有区域预览、诊断和构建中间产物的前提下增加明确的资产包发布阶段。
- R5提供 Cesium 与 Three.js 的最小加载示例,均从 manifest 读取包内相对 URL并按相同的
WGS84/ENU 契约放置主场景或分层资产。
- R6定义发布资产、可选静态资产和本项目仅检查产物的分类规则并由测试验证。
- R7完整构建后的资产包应可独立复制到其他项目而无需本仓库的 `outputs` 目录结构或本地路径。
## Acceptance Criteria
- [ ] 目标区域生成一个可独立分发的资产包目录,其中仅有 manifest 和 manifest 引用的发布资产。
- [ ] manifest 的 schema version、坐标契约、bounds、所有资产类别和 URL 可由程序校验。
- [ ] 所有 manifest URL 都是安全的包内相对路径;不得含绝对路径、`..` 或未声明文件。
- [ ] 主 GLB 与每个发布分层资产都能在 Cesium 和 Three.js 示例中按 manifest 正确放置与加载。
- [ ] 车辆、巡航路线、Cesium preview HTML/runtime、QGIS/GeoJSON/`.blend` 和动态信号调度资产
不会进入发布包。
- [ ] 既有 `outputs/<area>/` 预览工作流继续可用,现有非交互 build 命令保持兼容。
- [ ] 发布包缺文件、manifest 路径越界、坐标字段无效或资产类别不合法时,构建/校验非零退出。
## Proposed Delivery Phases
1. 契约与目录边界:冻结 manifest v1 schema、发布目录结构、资产角色和坐标定义。
2. 资产包发布阶段:从现有 Cesium 导出与压缩结果收集、校验并写入独立包目录。
3. 下游消费证明Cesium / Three.js 示例仅依赖 manifest 与包内容,并覆盖主场景和按类别加载。
4. 质量门与迁移:增加结构、路径隔离、坐标和加载验证;保留旧预览输出并记录迁移规则。
## Key Decision
- 发布包根目录固定为 `outputs/<area-id>/package/`。该目录是可整体复制给下游项目的唯一
发布边界GLB 和 manifest 直接生成或移动到这里,避免与工作目录再保留一套大模型副本。
本项目的预览页可通过包内相对路径读取发布资产,但 HTML/runtime 本身不属于发布包。
## Out Of Scope
- 交通流、车辆行为、信号相位控制、路口调度和其他运行时仿真。
- 在本任务中扩展 OSM 到建筑、路灯、标志、植被等资产覆盖率或生成质量。
- 删除既有 `outputs/<area>/` 中由用户保留的历史调试产物。

View File

@@ -0,0 +1,26 @@
{
"id": "asset-package-contract",
"name": "asset-package-contract",
"title": "Reusable asset package contract",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-11",
"completedAt": "2026-08-12",
"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,54 @@
# Interactive Area Build CLI Design
## Scope
Add a zero-dependency terminal interface for choosing an area configuration and one or more existing
pipeline stages. It delegates execution to the existing `build-area.js` entry point and does not change
the pipeline's stage implementations or generated artifacts.
## Architecture
```text
npm run build
-> scripts/interactive-build.js
-> terminal menus (TTY only)
-> node scripts/build-area.js --config <area> --stages <canonical-list>
-> existing build pipeline
```
Extract the stage metadata, aliases, canonical execution order, and mutual-exclusion validation from
`build-area.js` into a CommonJS module under `scripts/lib/`. Both the interactive script and
`build-area.js` consume this module, making the menu's options the same source of truth as execution.
## Interaction
1. Discover and sort `config/areas/*.json`; present a single-select area menu.
2. Present a multi-select stage menu with descriptions and selection markers.
3. Arrow keys move focus, space toggles, enter confirms, and Ctrl-C/Escape cancels.
4. The menu prevents or reports the `intermediates`/`reimport` conflict before spawning a build.
5. The selected stages are normalized to canonical pipeline order and passed to `build-area.js`.
The interactive entry requires both stdin and stdout to be TTYs. Otherwise it exits non-zero with an
instruction to use `npm run build:area -- --config ... --stages ...`; it never falls back to defaults.
## Compatibility
- `build-area.js` remains the execution owner and keeps its CLI flags and defaults.
- `npm run build:area` continues to invoke it directly.
- `npm run build` changes only from the former default build alias to the interactive wrapper.
- A successful `cesium` selection continues to generate preview output through the existing behavior;
selecting `preview` explicitly remains valid for a standalone preview refresh.
## Validation
- Unit-test stage normalization, alias expansion, ordering, and mutual exclusion.
- Unit-test config discovery and non-TTY refusal without launching a build.
- Syntax-check both CLI entry scripts.
- Manually exercise the menu in a TTY: select an area, select unordered stages, confirm the canonical
command preview, and cancel before execution.
## Risks And Rollback
Raw terminal input must always restore raw mode and cursor state on confirmation, cancellation, and
errors. The wrapper only spawns the unchanged existing build script, so rollback is deleting the new
interactive entry and restoring the `build` package script.

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,13 @@
# Interactive Area Build CLI Implementation Plan
1. Extract stage definitions, aliases, canonical ordering, and conflict validation from
`scripts/build-area.js` into a shared CommonJS module.
2. Update `build-area.js` to consume the shared resolver without changing non-interactive semantics.
3. Add `scripts/interactive-build.js` with TTY detection, area discovery, raw-mode selection menus,
cancellation handling, and child-process delegation to `build-area.js`.
4. Change `package.json` so `npm run build` calls the new interactive script while `build:area` remains
unchanged.
5. Add focused Node tests for stage planning and non-TTY behavior; do not invoke Blender, QGIS, or a
real area build in tests.
6. Run syntax checks, the focused tests, existing preflight/preview tests, and manually exercise the
interactive flow in a TTY.

View File

@@ -0,0 +1,53 @@
# Interactive area build CLI
## Goal
提供一个交互式区域构建入口,减少手写 `npm run build:area -- --config ... --stages ...`
的频率用户可选择区域和一个或多个构建阶段CLI 按既有阶段约束执行。
## Confirmed Facts
- 当前 `scripts/build-area.js` 支持 `intermediates``reimport``blender``cesium`
`preview``compress` 六个阶段,并接受逗号分隔的 `--stages`
- `intermediates``reimport` 互斥:前者会从 OSM 重建 GeoPackage后者回导 QGIS 编辑。
- 执行顺序固定为 `intermediates/reimport -> blender -> cesium -> preview -> compress`
`cesium` 当前会自动写入 preview 产物。
- 项目没有终端交互依赖;现有命令行调用必须继续可用于脚本和自动化。
## Requirements
- R1提供交互式入口列出可用区域配置并允许用户选择目标区域。
- R2交互式入口允许一次选择多个阶段并清晰展示阶段名称与用途。
- R3选择互斥阶段时必须阻止执行并解释原因不得静默选择其中之一。
- R4执行多个阶段时必须使用既有依赖顺序而非用户勾选顺序。
- R5保留 `npm run build:area -- --config ... --stages ...` 的现有非交互行为与语义。
- R6无 TTY、取消输入或无效输入时必须安全退出不启动任何构建。
- R7TTY 交互使用方向键移动、空格多选、回车确认;菜单应显示当前选择状态。
- R8`npm run build` 启动交互式入口;`npm run build:area` 继续保留为非交互入口。
## Acceptance Criteria
- [ ] 用户可从终端选择区域和多项阶段,无需手写配置路径或逗号分隔阶段名。
- [ ] 选择 `intermediates``reimport`CLI 明确报互斥错误且不运行构建。
- [ ] 选择乱序的多个阶段时,实际执行顺序仍遵循既有管线顺序。
- [ ] 既有非交互 `build:area` 调用保持兼容。
- [ ] 取消或在非交互环境调用不会触发构建。
- [ ] 用户可用方向键、空格和回车完成区域及阶段选择。
- [ ] `npm run build` 启动交互菜单,`npm run build:area` 的现有调用保持不变。
## Key Decisions
- 采用终端原生多选菜单,而非输入逗号分隔编号;避免引入运行时依赖。
- `npm run build` 作为日常交互入口;`npm run build:area` 继续服务自动化和精确调用。
## Out Of Scope
- 修改各 stage 的构建业务逻辑、依赖关系或产物格式。
- 将巡航路线、QGIS 图层编辑或区域配置编辑纳入本次交互流程。
- GUI 或浏览器界面的构建控制台。
## 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": "interactive-area-cli",
"name": "interactive-area-cli",
"title": "Interactive area build CLI",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-11",
"completedAt": "2026-08-11",
"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,48 @@
# Design: Vehicle Incident And Information Preview
## Boundary
This is browser-only Cesium preview state. It changes neither generated OSM data nor `package/` assets.
Vehicle routes and models remain `_preview/` resources; refresh resets all vehicle event state.
## Data Model
Each object returned by `addCruiseVehicle()` gains:
```js
{
id: "vehicle-1",
modelName: "car_a01_002",
status: "normal" | "breakdown" | "accident",
incidentNote: "",
motion: { state },
markerEntity
}
```
The current route distance remains the single motion source. The clock tick leaves it unchanged while
status is not `normal`; returning to normal resumes from that position.
## Interaction Flow
1. Vehicle Cesium Entities carry a `vehicleId` property.
2. A `ScreenSpaceEventHandler` picks a clicked entity, resolves its vehicle, synchronizes the existing
vehicle selector, and opens a positioned information card.
3. The card displays vehicle number, model family, route label/length, configured speed, state, and note.
4. Card controls set normal/breakdown/accident and persist only in the in-memory vehicle object.
5. State changes update marker visibility, the route material, card content, and Cesium render request.
## Visual Contract
- Normal: no marker; original route color; motion active.
- Breakdown: yellow wrench label above the vehicle; route retains its original color; motion frozen.
- Accident: red warning-triangle label above the vehicle; route material becomes red; motion frozen.
- Marker positions use the same vehicle `CallbackProperty`, so they move with the vehicle before an event
and remain at the incident location while stopped.
## Compatibility And Risks
- Picking must ignore routes, static models, and signal entities.
- No route must retain the existing disabled cruise controls and never show a card.
- The card must be HTML/CSS UI, not Cesium InfoBox, because the viewer deliberately disables InfoBox.
- The feature must not alter signal runtime behavior or vehicle asset URLs.

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,17 @@
# Implementation Plan
1. Add an initially hidden vehicle information card to `area-preview.js` and responsive card styles to
`cesium-preview.css`.
2. Extend the browser runtime vehicle records with stable identity, parsed model metadata, incident status,
note, and a marker entity.
3. Gate route-distance advancement on normal state; add status transition helper for marker, route material,
and card refresh.
4. Add Cesium click picking and card controls. Keep the existing selector synchronized for follow/route use.
5. Add focused assertions to `test-preview-assets.js` for card wiring, status behavior, and marker/route
contracts.
6. Run syntax checks, preview test, stage test, target-area preview regeneration, and `check:area`.
## Rollback
Revert only the preview HTML/CSS/runtime/test files. No package, Blender, route, or OSM product files should
need rollback.

View File

@@ -0,0 +1,38 @@
# Vehicle incident and information preview
## Goal
在 Cesium 验证预览中,让使用者能够查看每辆巡航车辆的基础信息,并人为标记车辆事故/故障状态,直观看到该状态对车辆和路线展示的影响。
## Confirmed Facts
- 车辆是 `scripts/lib/cesium-preview.js` 创建的 Cesium Entity当前已有车型、路线、巡航速度、
跟随、显隐与路线选择。
- 车辆模型和路线都属于 `_preview/` 验证资源,不是 area package 的下游资产契约。
- 当前不存在车辆业务资料、事故状态、碰撞检测或自动事件推演。
- 项目定位是资产生成与验证,不能将交通流/事故仿真作为本任务的范围。
## Requirements
- 每辆预览车辆必须有稳定的显示编号、车型、当前路线、设定速度和行驶状态。
- 点击场景中的车辆后,必须弹出该车辆的信息卡,展示基础信息与当前事件状态;信息卡内完成状态与说明编辑。
- 使用者必须能将选中车辆在正常、故障和事故状态之间切换,并能提供简短事件说明。
- 故障和事故是不同状态:二者均停止巡航;故障使用黄色标记,事故使用红色标记并提示路线中断。
- 故障使用车辆顶部黄色扳手标记;事故使用红色警示三角标记,并将该车辆当前路线改为红色。
- 非正常车辆必须有可见的场景标记;恢复正常后继续按既有路线巡航。
- 事故/故障仅在当前浏览器预览会话中存在;刷新或重建预览后不保留。
## Out of Scope
- 自动碰撞检测、车辆间物理碰撞、自动事故生成。
- 信号配时、交通流、调度策略或持久化事件记录。
-`package/` 加入车辆、路线或事故数据。
## Acceptance Criteria
- [ ] 用户可点击任一可见车辆,弹出其基础信息、状态和事件说明;现有车辆选择控件仍可用于路线和跟随。
- [ ] 用户可标记故障或事故,并看到车辆停止、状态文本和明显的场景标记。
- [ ] 故障显示黄色扳手;事故显示红色警示三角且当前路线变红。
- [ ] 用户可恢复正常,车辆继续其现有路线;其他车辆不受影响。
- [ ] 无可用路线时,车辆信息和事故控件按既有禁用语义处理。
- [ ] 预览资产、路线与现有信号灯 runtime 继续可加载;不新增下游 package 内容。

View File

@@ -0,0 +1,26 @@
{
"id": "vehicle-incident-info",
"name": "vehicle-incident-info",
"title": "Vehicle incident and information preview",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-12",
"completedAt": "2026-08-12",
"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,77 @@
# Native Lane Markings Design
## Architecture
The native compiler remains the source of truth. It derives two new polygon
layers alongside its existing surface, sidewalk, lane-centerline, and
connector outputs:
```text
canonical directed roads + lane centerlines + junction cutbacks
|
+-- lane separators: paint polygons between adjacent same-direction lanes
|
+-- direction arrows: repeated through-arrow template on directed lanes,
| outside the reserved junction marking zone
|
+-- turn arrows: tested existing template, anchored to an incoming lane
only when that lane has an explicit supported turn:lanes value
```
No OSM2streets rendered geometry is consumed by this path. The existing arrow
template library is reused only as a geometry/style asset, so the native lane
ID and OSM tags remain the evidence for placement.
## Contracts
- `layers/lane_separators.geojson`: polygon FeatureCollection. Each feature
records `native_id`, directed `road_id`, adjacent lane indices, source OSM
ways, and `native-road-lane-separator/v1` provenance.
- `layers/turn_arrows.geojson`: polygon FeatureCollection. Each feature records
its `native_id`, `road_id`, `lane_id`, OSM way IDs, direction, lane index,
maneuver, template asset, placement distance, and placement provenance.
- Unsupported turn values, a missing usable incoming-lane segment, or an
insufficient pre-junction placement distance create a diagnostic and no
arrow geometry.
- `layers/direction_arrows.geojson`: polygon FeatureCollection. Each feature
records the native lane and directed road, OSM way IDs, a stable sequence
index, its distance along the lane, and `native-road-direction-arrow/v1`
provenance. It uses the tested `through` template but is not a turn claim.
- The workbench serves both layers, draws them separately from its current
centerline/connector debug layer, and selects them by `native_id`.
- `catalog.NATIVE_ROAD_LAYERS` maps the two native sources to the existing
`lane_separators` and `lane_arrows_webscale` Blender materials. The native
adapter does not extend the osm2streets scene-layer registry.
## Placement
An incoming lane is oriented in driving direction. A turn arrow is sampled
from that lane's endpoint backwards by the configured safe distance, staying
outside the junction cutback. Its template basis uses the sampled lane tangent;
therefore it is on and aligned with the lane rather than the OSM centerline or
a screen-space direction. Multiple template rings remain separate polygons.
Lane separators are narrow polygons centered between adjacent lane centerlines
on a single directional carriageway. They stop at the same junction cutbacks as
the lane centerlines. A one-lane direction produces none.
Direction arrows are sampled at a fixed road-scale interval along the same
directed lane centerline. Their candidates exclude both endpoint buffers and
the turn-arrow reserve at the incoming end. This preserves a readable repeated
direction cue without overlapping a turn instruction at a junction.
## Compatibility And Rollback
All new files are additive under `native-road/layers/`. The existing
osm2streets/QGIS layer contract and `package/` are unchanged. Selecting
`--road-provider osm2streets` remains rollback. Missing native marking files
are a native Blender build error rather than a silent omission.
## Risks
- OSM turn tags can be incomplete or incompatible with the inferred lane
count. These are diagnostics, not guessed arrows.
- Very short approaches can have no safe position before the cutback. They are
skipped with a source-traceable diagnostic.
- Blender and Cesium need a real native build to verify the mesh/material
contract, not only GeoJSON unit tests.

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,31 @@
# Implementation Plan
1. Add reusable template-placement helper to the existing turn-arrow module,
preserving its supported-asset gate and output ring shape.
2. Extend the native compiler with lane-separator, repeated road-direction
arrow, and explicit-turn-arrow polygon generation plus source diagnostics
and layer persistence.
3. Extend native output records/counts and Blender adapter mappings. Reuse
existing `lane_separators` and `lane_arrows_webscale` materials only.
4. Add Workbench layers, toggles, selection/provenance inspector entries, and
preserve the existing Workbench-only direction triangle behavior.
5. Add focused fixtures for supported turn placement, unsupported maneuver,
short approach skip, and separator geometry; run native/workbench/build
stage tests.
6. Build Nantaizi with `blender,cesium,preview --road-provider native`, inspect
the final preview, and confirm no `package/` publication occurred.
## Validation
```bash
npm run test:native-road
npm run test:road-workbench
npm run test:turn-lane-arrows
npm run test:build-stages
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json \
--stages blender,cesium,preview --road-provider native
```
Rollback is selecting `--road-provider osm2streets`; no existing output path
is replaced.

View File

@@ -0,0 +1,74 @@
# Native road lane markings and turn arrows
## Goal
Complete the native-road visual language for Nantaizi before any cross-area
migration: lane separators, travel-direction markers, and OSM-backed turn
arrows must be inspectable in the Road Workbench and visible in Blender and
Cesium output.
## Confirmed Facts
- Native output currently contains directed lane centerlines and connector
curves, but it does not emit paintable lane-separator or turn-arrow polygons.
- The existing osm2streets path has tested arrow templates in
`scripts/lib/turn-lane-arrows.js`, the `lane_arrows_webscale` material layer,
and a matching Blender material. Reuse these instead of introducing a second
arrow style.
- Earlier reviews established that direction markers must sit on the OSM / lane
centerline, use a clearly directional sharp triangle, and never be treated as
a road-surface decoration that drifts sideways.
- Turn arrows must follow the actual incoming lane and be placed before its
junction, with OSM source way, direction, lane index, and maneuver retained
as provenance.
- Scope remains Nantaizi only. Existing osm2streets output remains untouched.
## Requirements
- R1: Native compilation emits polygonal lane-separator markings derived from
its own directed lane geometry.
- R2: Native compilation emits turn-arrow polygons for supported OSM
`turn:lanes` maneuvers, using the existing tested arrow templates and the
exact native incoming-lane centerline for placement.
- R2a: Native compilation emits repeated straight-ahead direction-arrow
polygons along directed lanes, matching the visual role of osm2streets'
ordinary road arrows. These are a separate layer from turn arrows, retain
their own provenance, and leave a clear buffer around junction turn arrows.
- R3: Native output preserves provenance for every marking: native road/lane
ID, OSM way IDs, direction, lane number, maneuver, and placement method.
- R4: The Workbench renders markings in a separately controllable layer and
exposes those provenance fields on selection.
- R5: The native Blender adapter consumes native marking layers through the
existing lane-separator and lane-arrow material layers; Cesium must receive
the same geometry through the exported GLB.
- R6: Unsupported, unplaceable, or ambiguous arrow inputs become diagnostics;
the compiler must not invent a maneuver.
## Acceptance Criteria
- [ ] Nantaizi native output contains valid polygon GeoJSON for generated lane
separators, repeated road direction arrows, and every supported, explicitly
tagged turn arrow.
- [ ] A Workbench user can toggle, select, and inspect a generated marker and
see its lane, OSM, maneuver, and placement provenance.
- [ ] A selected direction marker is geometrically aligned to its directed lane
centerline; a selected turn arrow is on its incoming lane before the junction.
- [ ] Blender scene output and Cesium GLB contain native lane markings and
arrows with the existing visual material language.
- [ ] Unit tests cover a normal supported arrow, an unsupported maneuver, and
an unsafe/too-short placement; native compile and final Nantaizi visual build
pass without publishing `package/`.
## Out Of Scope
- Inventing turn arrows for untagged lanes, traffic-control semantics, changing
QGIS/osm2streets layers, or processing another area.
## Key Decision
- Sharp travel-direction triangles remain a Workbench-only inspection aid.
They explain raw OSM node order after a road is selected.
- Repeated `through` direction arrows are final road markings, distinct from
both those debug triangles and OSM-backed junction turn arrows. They are
placed on native lane centerlines at a fixed interval and enter the same
Blender/Cesium material layer as turn arrows.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-lane-markings",
"name": "native-road-lane-markings",
"title": "Native road lane markings and turn arrows",
"description": "Complete Nantaizi native lane separators, travel direction and turn-arrow geometry through Road Workbench, Blender and Cesium before cross-area migration.",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-14",
"completedAt": "2026-08-14",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 25
- **Last Active**: 2026-08-06
- **Total Sessions**: 31
- **Last Active**: 2026-08-14
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~533 | Active |
| `journal-1.md` | ~663 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,12 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 31 | 2026-08-14 | Native road lane markings | `1b9829d` | `feature/native-road-compiler` |
| 30 | 2026-08-12 | Add vehicle incident preview cards | `761a526` | `main` |
| 29 | 2026-08-12 | Publish reusable area asset packages | `f385009`, `c925890`, `db0fba5`, `f2b8d79`, `0102ffb` | `main` |
| 28 | 2026-08-11 | 默认压缩交付 | `0790cbd` | `main` |
| 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` |

View File

@@ -531,3 +531,133 @@ Added shared 7LED countdown geometry, split dynamic Cesium assets by phase group
### 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**
## Session 28: 默认压缩交付
**Date**: 2026-08-11
**Task**: 默认压缩交付
**Branch**: `main`
### Summary
将 GLB 压缩纳入默认构建并使用标准产物路径交付,移除并列压缩输出契约;同步诊断、测试与文档。
### Git Commits
| Hash | Message |
|------|---------|
| `0790cbd` | (see git log) |
### Status
[OK] **Completed**
## Session 29: Publish reusable area asset packages
**Date**: 2026-08-12
**Task**: Publish reusable area asset packages
**Branch**: `main`
### Summary
Published the reusable osm-asset-package/v1 contract, moved static models under package/, kept dynamic cruise and signal assets in _preview/, fixed preview manifest-relative loading, and verified the target area quality gate.
### Git Commits
| Hash | Message |
|------|---------|
| `f385009` | (see git log) |
| `c925890` | (see git log) |
| `db0fba5` | (see git log) |
| `f2b8d79` | (see git log) |
| `0102ffb` | (see git log) |
### Status
[OK] **Completed**
## Session 30: Add vehicle incident preview cards
**Date**: 2026-08-12
**Task**: Add vehicle incident preview cards
**Branch**: `main`
### Summary
Added click-to-open vehicle information cards with normal, breakdown, and accident preview-only states, floating vehicle-anchored cards, route and motion changes, plus bundled repair and warning icons.
### Git Commits
| Hash | Message |
|------|---------|
| `761a526` | (see git log) |
### Status
[OK] **Completed**
## Session 31: Native road lane markings
**Date**: 2026-08-14
**Task**: Native road lane markings
**Branch**: `feature/native-road-compiler`
### Summary
Implemented native lane separators, repeated road direction arrows, explicit junction turn arrows, Workbench inspection/toggles, and Blender/Cesium adapter support for Nantaizi; validated native compile, checks, tests, and non-package visual build.
### Git Commits
| Hash | Message |
|------|---------|
| `1b9829d` | (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

@@ -4,15 +4,19 @@
## 目标产物
每个区域默认输出到 `outputs/<area-id>/`
每个区域默认输出到 `outputs/<area-id>/`。其中只有 `package/` 是可复制给下游的发布边界
- `package/manifest.json`:版本化 `osm-asset-package/v1` manifest声明 WGS84 anchor、ENUX east/Y north/Z up、heading correction 和包内模型 URL
- `package/models/<area-id>.glb`:完整静态场景;`roads.glb``buildings.glb``vegetation.glb``water.glb` 是可选重叠分层,默认不要与完整场景同时加载
其余内容仍是本项目工作目录:
- `<area-id>.blend`Blender 场景,包含道路、建筑、水体、植被等
- `<area-id>.png`Blender 预览渲染
- `<area-id>.glb`Cesium 可加载的完整 3D 模型(基线产物)
- `<area-id>.glb`Cesium 可加载的完整 3D 模型(默认压缩交付产物)
- `<area-id>-roads.glb``-buildings.glb``-vegetation.glb``-water.glb`Cesium 分类检查用的辅助模型
- `<area-id>.json`Cesium 放置元数据和示例代码
- `<area-id>-cesium-preview.html`Cesium 本地预览页
- `<area-id>-compressed-webp768.glb/json/html`:显式 `compress` 阶段生成的可选压缩预览产物
- `osm2streets_web_out/`osm2streets GeoJSON 中间层
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`QGIS 调试资产
@@ -37,12 +41,14 @@ npm install
## 主流程
默认构建南台子湖创新谷样例
交互式选择区域和构建阶段
```bash
npm run build
```
使用方向键移动、空格多选阶段、回车开始构建;`intermediates``reimport` 不能同时选择。
指定区域配置:
```bash
@@ -60,15 +66,14 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport
```
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png``cesium``.blend` 导出 `.glb`/`.json`,并生成 Cesium 预览 HTML。`preview` 只在已有 `.glb/.json` 时补生成 HTML。`compress` 从已有 `.glb/.json/html` 生成并列压缩产物,不覆盖默认 GLB`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png``cesium``.blend` 导出包 staging`compress` 压缩 staging 主 GLB`package` 校验并原子发布 `package/``preview` 生成仅用于本地验证的 HTML 和 `_preview/` 动态描述符`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB该模式用于单独检查生成结果主 GLB 仍是完整场景和下游使用的基线
压缩预览同样保留这些分类检查资产;重跑 `compress` 后应打开新生成的压缩预览 HTML。
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB该模式用于单独检查生成结果主 GLB 是压缩后的下游交付资产
`compress` 含在 `all` 里,也不能从配置文件默认开启。需要重导出 Cesium 后立刻生成压缩产物时,显式跑:
`compress` 含在完整构建的默认阶段和 `all` 中。需要对已有标准产物重新压缩时,可以显式跑:
```bash
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress
```
## 区域诊断
@@ -91,6 +96,37 @@ npm run preflight:area -- --config config/areas/nantaizi-lake-innovation-valley.
预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次
验证的 config / OSM 文件摘要和检查结果。
## Native Road Workbench
`road:compile` 是独立于 osm2streets 的实验性道路编译器入口。它从 OSM 生成可追溯的
道路模型、基础道路面、诊断与对比摘要,写入 `outputs/<area-id>/native-road/`,不会覆盖
`osm2streets_web_out/` 或影响现有 Blender/Cesium 构建:
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
```
`road:check` 验证已发布 connector 与语义行驶动作的一致性,并在诊断包含 error、图层缺失或
动作/几何对应关系不一致时以非零退出。warning 保留给工作台审查,不会阻止产物打开。
启动本地浏览器工作台:
```bash
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
```
工作台默认先编译,并在 `http://127.0.0.1:8787/` 启动基于 OpenLayers 的 GIS 工作台,展示
OSM 道路、原生结果、车道、行驶动作、诊断和每个值的来源。可编辑道路宽度、车道数、两侧
人行道,以及候选路口端点连接。保存写入
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
道路按行驶方向显示:点击道路面、中心线、车道或转向路径后,蓝色三角形标记当前方向在 OSM
原始中心线上的方向;右侧“路口连接”只列出该方向到达终点路口后可驶入的目标道路,并标记为
左转、直行、右转或掉头。车道按当前方向从左向右编号。内部断头诊断中的近邻候选不会自动连接,
必须由用户显式暂存、保存并重新编译。
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。
缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`
@@ -113,7 +149,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`
里标出来。
@@ -207,8 +245,7 @@ QGIS road-layer knobs:
"areaDir": "/absolute/path/to/custom-area",
"blend": "/absolute/path/to/custom.blend",
"glb": "/absolute/path/to/custom.glb",
"cesiumPreview": "/absolute/path/to/custom-preview.html",
"compressedGlb": "/absolute/path/to/custom-compressed.glb"
"cesiumPreview": "/absolute/path/to/custom-preview.html"
}
}
```
@@ -224,7 +261,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 的轻量预览模型,生成在输出目录中。
@@ -273,8 +310,8 @@ Cesium GLB 低层导出:
## 压缩 GLB
推荐使用显式 `compress` 阶段。它不会覆盖默认 `<area-id>.glb`,只生成并列的压缩 GLB、
metadata 和预览页
完整构建默认运行 `compress`。它将未压缩 Cesium 导出复制到 `_pipeline` 下的临时目录,
压缩成功后才替换标准 `<area-id>.glb``.json` 和预览页;最终输出目录不保留并列压缩文件
```bash
npm run build:area -- \
@@ -287,7 +324,7 @@ npm run build:area -- \
```bash
npm run compress:glb -- \
--input outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.glb \
--output outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp768.glb \
--output /tmp/nantaizi-lake-innovation-valley-compressed.glb \
--texture-size 768 \
--metadata outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.json \
--preview outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-cesium-preview.html
@@ -308,7 +345,7 @@ npm run compress:glb -- \
南台子湖创新谷当前可直接使用下面这条命令:
```bash
npm run build -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport,blender,cesium
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport,blender,cesium
```
`reimport` 阶段(`scripts/reimport-gpkg.js`)做两件事:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

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

@@ -579,7 +579,8 @@ def export_glb(filepath, meshes):
def semantic_asset_specs(glb_path, meshes):
"""Derive optional inspection assets from the scene's named collections."""
stem, extension = os.path.splitext(glb_path)
model_dir, filename = os.path.split(glb_path)
_, extension = os.path.splitext(filename)
by_collection = {}
for collection in bpy.context.scene.collection.children:
by_collection[collection.name] = set(collection.all_objects)
@@ -592,7 +593,7 @@ def semantic_asset_specs(glb_path, meshes):
subset = [obj for obj in meshes if obj in objects]
if not subset:
continue
path = stem + "-" + asset_id + extension
path = os.path.join(model_dir, asset_id + extension)
specs.append({
"id": asset_id,
"label": label,
@@ -656,6 +657,8 @@ def export(args):
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 args.get(key):
continue
if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group])
@@ -673,42 +676,27 @@ def export(args):
center_lat = (bounds["min_lat"] + bounds["max_lat"]) / 2.0
else:
center_lon = center_lat = 0.0
area_id = os.path.splitext(os.path.basename(args["glb"]))[0]
runtime_assets = []
if args.get("dynamic_glb"):
runtime_assets.append({"id": "traffic-signals-dynamic", "type": "traffic-signal-lenses", "uri": "runtime/traffic-signals-dynamic.glb"})
if args.get("countdown_0_glb"):
runtime_assets.append({"id": "traffic-signals-countdown-0", "type": "traffic-signal-countdown", "phaseGroup": 0, "uri": "runtime/traffic-signals-countdown-0.glb"})
if args.get("countdown_1_glb"):
runtime_assets.append({"id": "traffic-signals-countdown-1", "type": "traffic-signal-countdown", "phaseGroup": 1, "uri": "runtime/traffic-signals-countdown-1.glb"})
metadata = {
"asset": os.path.basename(args["glb"]),
"assets": [{
"id": "main",
"label": "Scene",
"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"],
"type": "model",
"url": os.path.basename(asset["path"]),
"enabled": False,
"category": "semantic",
"schema": "osm-asset-package/v1",
"packageVersion": "1.0.0",
"areaId": area_id,
"coordinateSystem": {"axes": "ENU", "units": "meters", "x": "east", "y": "north", "z": "up"},
"placement": {"longitude": center_lon, "latitude": center_lat, "height": 0.35, "headingCorrectionDegrees": -90.0},
"bounds": {"minLon": bounds.get("min_lon", center_lon), "minLat": bounds.get("min_lat", center_lat), "maxLon": bounds.get("max_lon", center_lon), "maxLat": bounds.get("max_lat", center_lat)},
"assets": [{"id": "main", "role": "scene", "category": "scene", "uri": "models/" + os.path.basename(args["glb"]), "defaultLoad": True}] + [{
"id": asset["id"], "role": "layer", "category": asset["id"],
"uri": "models/" + os.path.basename(asset["path"]), "defaultLoad": False,
} for asset in semantic_assets],
"coordinate_system": "local ENU meters (X east, Y north, Z up)",
"heading_correction_degrees": -90.0,
"anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35},
"bounds": bounds,
"source_osm": scene.get("source_osm", ""),
"source_geojson": scene.get("source_geojson", ""),
"scene_stats": {
"runtime": runtime_assets,
"sceneStats": {
"buildings": scene.get("building_count", 0),
"industrial_buildings": scene.get("industrial_building_count", 0),
"lake_polygons": scene.get("lake_count", 0),
@@ -717,15 +705,6 @@ def export(args):
"scrub_polygons": scene.get("scrub_count", 0),
"fountains": scene.get("fountain_count", 0),
},
"cesium_js": (
"const p = Cesium.Cartesian3.fromDegrees(" +
f"{center_lon:.8f}, {center_lat:.8f}, 0.35);\n" +
"const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);\n" +
"const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(-90.0));\n" +
"const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());\n" +
"Cesium.Model.fromGltfAsync({ url: '" + os.path.basename(args["glb"]) +
"', modelMatrix }).then(model => viewer.scene.primitives.add(model));"
),
}
os.makedirs(os.path.dirname(args["metadata"]), exist_ok=True)
with open(args["metadata"], "w", encoding="utf-8") as handle:

View File

@@ -110,7 +110,7 @@ TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLE
def cli_args():
values = {"osm": None, "geojson": None, "output": None, "render": None,
values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None,
"office_overrides": "", "tree_style": "natural"}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
@@ -789,8 +789,23 @@ def build(args):
_features.dispatch_ways(ways, projector, way_handlers)
geojson_dir = args.get("geojson")
native_road_dir = args.get("native_road")
road_counts = {}
if geojson_dir and os.path.isdir(geojson_dir):
if native_road_dir:
if not os.path.isdir(native_road_dir):
raise RuntimeError("--native-road directory does not exist: " + native_road_dir)
material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS}
for source in catalog.NATIVE_ROAD_LAYERS:
target = source["material_layer"]
layer = material_layers[target]
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
count = _roads.assemble_geojson_layer(
source_path, source["source"], projector, roads_c,
road_mats[target], layer["z"])
if count == 0:
raise RuntimeError("Native road layer has no usable geometry: " + source_path)
road_counts[source["source"]] = count
elif geojson_dir and os.path.isdir(geojson_dir):
for problem in catalog.check_layers(geojson_dir):
print("Layer catalog warning:", problem)
for layer in catalog.ROAD_LAYERS:
@@ -799,7 +814,7 @@ def build(args):
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
projector, roads_c, road_mats[layer_id], layer["z"])
if road_counts.get("road_surface", 0) == 0:
if not native_road_dir and road_counts.get("road_surface", 0) == 0:
_roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"])
@@ -910,6 +925,7 @@ def build(args):
scene.render.filepath = args["render"]
scene["source_osm"] = args["osm"]
scene["source_geojson"] = geojson_dir or ""
scene["source_native_road"] = native_road_dir or ""
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
scene["building_count"] = counts["building_count"]
scene["industrial_building_count"] = counts["industrial_count"]

View File

@@ -48,6 +48,17 @@ ROAD_LAYERS = [
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
# Native-road output intentionally maps into existing scene material layers.
# It is a provider adapter, not a second scene-layer registry.
NATIVE_ROAD_LAYERS = (
{"source": "road_surface", "material_layer": "road_surface"},
{"source": "intersection_surface", "material_layer": "intersection_surface"},
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
{"source": "lane_separators", "material_layer": "lane_separators"},
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
)
# Material specs. `kind` selects the builder:
# solid — flat base colour

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

@@ -129,8 +129,9 @@ def assemble_dynamic(signal_data, projector, collection, materials):
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" % (signal["id"], state), collection, materials[state])
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:
@@ -149,7 +150,7 @@ def assemble_dynamic(signal_data, projector, collection, materials):
phase_group = int(signal.get("phaseGroup") or 0) % 2
for value, mesh in countdown_meshes[phase_group].items():
objects.append(_countdown_instance(
"TrafficSignalDynamic_%s_countdown_%s" % (signal["id"], value),
"TrafficSignalDynamic_%s_countdown_%s" % (node_key, value),
mesh, collection, text_x, text_y, board_z, lateral, face))
return objects

View File

@@ -30,7 +30,7 @@ from osmassets.geom import (
sample_tree_row,
signed_polygon_area,
)
from osmassets.catalog import ROAD_LAYERS
from osmassets.catalog import NATIVE_ROAD_LAYERS, ROAD_LAYERS
from osmassets.osm import Projector, parse_height, parse_osm, tags
@@ -46,6 +46,24 @@ class RoadLayerCatalogTest(unittest.TestCase):
self.assertNotEqual(layers["road_surface"]["z"],
layers["intersection_surface"]["z"])
def test_native_provider_maps_to_existing_material_layers(self):
layers = {layer["id"] for layer in ROAD_LAYERS}
self.assertEqual(
[(entry["source"], entry["material_layer"])
for entry in NATIVE_ROAD_LAYERS],
[("road_surface", "road_surface"),
("intersection_surface", "intersection_surface"),
("sidewalk_surface", "sidewalks")])
self.assertTrue(all(entry["material_layer"] in layers
for entry in NATIVE_ROAD_LAYERS))
def test_cesium_export_keeps_signal_assets_optional(self):
exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "export_cesium.py")
with open(exporter, encoding="utf-8") as handle:
source = handle.read()
self.assertIn('if not args.get(key):\n continue', source)
class GeometryRingsTest(unittest.TestCase):
def test_polygon_keeps_only_the_exterior_ring(self):
@@ -238,14 +256,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

@@ -33,7 +33,8 @@
},
"blender": {
"treeStyle": "natural",
"officeOverrides": ""
"officeOverrides": "",
"roadProvider": "osm2streets"
},
"compress": {
"textureSize": 768,

View File

@@ -0,0 +1,19 @@
"use strict";
function resolveAsset(manifestUrl, manifest, id = "main") {
const asset = manifest.assets.find((candidate) => candidate.id === id);
if (!asset) throw new Error(`Unknown asset '${id}'`);
return new URL(asset.uri, manifestUrl).href;
}
function resolveRuntime(manifestUrl, manifest, id) {
const runtime = (manifest.runtime || []).find((candidate) => candidate.id === id);
if (!runtime) throw new Error(`Unknown runtime asset '${id}'`);
return new URL(runtime.uri, manifestUrl).href;
}
function placement(manifest) {
return { ...manifest.placement, coordinateSystem: manifest.coordinateSystem };
}
module.exports = { resolveAsset, resolveRuntime, placement };

View File

@@ -0,0 +1,20 @@
"use strict";
const { resolveAsset, resolveRuntime, placement } = require("./asset-package-resolver");
async function loadPackage(viewer, manifestUrl, assetId = "main") {
const manifest = await fetch(manifestUrl).then((response) => {
if (!response.ok) throw new Error(`Could not load manifest: ${response.status}`);
return response.json();
});
const p = placement(manifest);
const origin = Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude, p.height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const rotation = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(p.headingCorrectionDegrees));
const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, rotation, new Cesium.Matrix4());
const model = await Cesium.Model.fromGltfAsync({ url: resolveAsset(manifestUrl, manifest, assetId), modelMatrix });
viewer.scene.primitives.add(model);
return { manifest, model, placement: p, runtime: (id) => resolveRuntime(manifestUrl, manifest, id) };
}
module.exports = { loadPackage };

View File

@@ -0,0 +1,17 @@
"use strict";
const { resolveAsset, resolveRuntime, placement } = require("./asset-package-resolver");
async function loadPackage(loader, manifestUrl, assetId = "main", handoffEnuPlacement) {
const manifest = await fetch(manifestUrl).then((response) => {
if (!response.ok) throw new Error(`Could not load manifest: ${response.status}`);
return response.json();
});
const scene = await loader.loadAsync(resolveAsset(manifestUrl, manifest, assetId));
// Three.js does not georeference local ENU itself; the embedding host owns it.
const enuPlacement = placement(manifest);
if (handoffEnuPlacement) handoffEnuPlacement(enuPlacement, scene.scene);
return { manifest, scene: scene.scene, placement: enuPlacement, runtime: (id) => resolveRuntime(manifestUrl, manifest, id) };
}
module.exports = { loadPackage };

617
package-lock.json generated
View File

@@ -8,14 +8,631 @@
"name": "osm-asset-pipeline",
"version": "0.3.0",
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}
},
"node_modules/@inquirer/ansi": {
"version": "2.0.7",
"resolved": "http://172.16.1.86:4873/@inquirer/ansi/-/ansi-2.0.7.tgz",
"integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
}
},
"node_modules/@inquirer/checkbox": {
"version": "5.2.1",
"resolved": "http://172.16.1.86:4873/@inquirer/checkbox/-/checkbox-5.2.1.tgz",
"integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/confirm": {
"version": "6.1.1",
"resolved": "http://172.16.1.86:4873/@inquirer/confirm/-/confirm-6.1.1.tgz",
"integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/core": {
"version": "11.2.1",
"resolved": "http://172.16.1.86:4873/@inquirer/core/-/core-11.2.1.tgz",
"integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7",
"cli-width": "^4.1.0",
"fast-wrap-ansi": "^0.2.0",
"mute-stream": "^3.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/editor": {
"version": "5.2.2",
"resolved": "http://172.16.1.86:4873/@inquirer/editor/-/editor-5.2.2.tgz",
"integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/external-editor": "^3.0.3",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/expand": {
"version": "5.1.1",
"resolved": "http://172.16.1.86:4873/@inquirer/expand/-/expand-5.1.1.tgz",
"integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/external-editor": {
"version": "3.0.3",
"resolved": "http://172.16.1.86:4873/@inquirer/external-editor/-/external-editor-3.0.3.tgz",
"integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==",
"license": "MIT",
"dependencies": {
"chardet": "^2.1.1",
"iconv-lite": "^0.7.2"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/figures": {
"version": "2.0.7",
"resolved": "http://172.16.1.86:4873/@inquirer/figures/-/figures-2.0.7.tgz",
"integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
}
},
"node_modules/@inquirer/input": {
"version": "5.1.2",
"resolved": "http://172.16.1.86:4873/@inquirer/input/-/input-5.1.2.tgz",
"integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/number": {
"version": "4.1.1",
"resolved": "http://172.16.1.86:4873/@inquirer/number/-/number-4.1.1.tgz",
"integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/password": {
"version": "5.1.1",
"resolved": "http://172.16.1.86:4873/@inquirer/password/-/password-5.1.1.tgz",
"integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/prompts": {
"version": "8.5.2",
"resolved": "http://172.16.1.86:4873/@inquirer/prompts/-/prompts-8.5.2.tgz",
"integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==",
"license": "MIT",
"dependencies": {
"@inquirer/checkbox": "^5.2.1",
"@inquirer/confirm": "^6.1.1",
"@inquirer/editor": "^5.2.2",
"@inquirer/expand": "^5.1.1",
"@inquirer/input": "^5.1.2",
"@inquirer/number": "^4.1.1",
"@inquirer/password": "^5.1.1",
"@inquirer/rawlist": "^5.3.1",
"@inquirer/search": "^4.2.1",
"@inquirer/select": "^5.2.1"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/rawlist": {
"version": "5.3.1",
"resolved": "http://172.16.1.86:4873/@inquirer/rawlist/-/rawlist-5.3.1.tgz",
"integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/search": {
"version": "4.2.1",
"resolved": "http://172.16.1.86:4873/@inquirer/search/-/search-4.2.1.tgz",
"integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/select": {
"version": "5.2.1",
"resolved": "http://172.16.1.86:4873/@inquirer/select/-/select-5.2.1.tgz",
"integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/type": {
"version": "4.0.7",
"resolved": "http://172.16.1.86:4873/@inquirer/type/-/type-4.0.7.tgz",
"integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "http://172.16.1.86:4873/@petamoriken/float16/-/float16-3.9.3.tgz",
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"license": "MIT"
},
"node_modules/@types/rbush": {
"version": "4.0.0",
"resolved": "http://172.16.1.86:4873/@types/rbush/-/rbush-4.0.0.tgz",
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"license": "MIT"
},
"node_modules/@zarrita/storage": {
"version": "0.2.0",
"resolved": "http://172.16.1.86:4873/@zarrita/storage/-/storage-0.2.0.tgz",
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
"license": "MIT",
"dependencies": {
"reference-spec-reader": "^0.2.0",
"unzipit": "2.0.0"
}
},
"node_modules/chardet": {
"version": "2.2.0",
"resolved": "http://172.16.1.86:4873/chardet/-/chardet-2.2.0.tgz",
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
"license": "MIT"
},
"node_modules/cli-width": {
"version": "4.1.0",
"resolved": "http://172.16.1.86:4873/cli-width/-/cli-width-4.1.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
"license": "ISC",
"engines": {
"node": ">= 12"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "http://172.16.1.86:4873/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/fast-string-truncated-width": {
"version": "3.0.3",
"resolved": "http://172.16.1.86:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "3.0.2",
"resolved": "http://172.16.1.86:4873/fast-string-width/-/fast-string-width-3.0.2.tgz",
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^3.0.2"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.2.2",
"resolved": "http://172.16.1.86:4873/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
"integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^3.0.2"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "http://172.16.1.86:4873/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/geotiff": {
"version": "3.1.0-beta.0",
"resolved": "http://172.16.1.86:4873/geotiff/-/geotiff-3.1.0-beta.0.tgz",
"integrity": "sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==",
"license": "MIT",
"dependencies": {
"@petamoriken/float16": "^3.9.3",
"lerc": "^3.0.0",
"pako": "^2.0.4",
"parse-headers": "^2.0.2",
"quick-lru": "^6.1.1",
"web-worker": "^1.5.0",
"xml-utils": "^1.10.2",
"zstddec": "^0.2.0"
},
"engines": {
"node": ">=10.19"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "http://172.16.1.86:4873/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "http://172.16.1.86:4873/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"license": "Apache-2.0"
},
"node_modules/mute-stream": {
"version": "3.0.0",
"resolved": "http://172.16.1.86:4873/mute-stream/-/mute-stream-3.0.0.tgz",
"integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/numcodecs": {
"version": "0.3.2",
"resolved": "http://172.16.1.86:4873/numcodecs/-/numcodecs-0.3.2.tgz",
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.0"
}
},
"node_modules/ol": {
"version": "10.10.0",
"resolved": "http://172.16.1.86:4873/ol/-/ol-10.10.0.tgz",
"integrity": "sha512-tLPKn6zl+6uWdPufYlqG/lQzuVUTVmfwahQqVr5+wZNyZecyAtIhMTyOtKpu7ooNDLY2sEjKZNXw9HL+sOjC1A==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/rbush": "4.0.0",
"earcut": "^3.0.0",
"geotiff": "^3.1.0-beta.0",
"pbf": "5.1.2",
"rbush": "^4.0.0",
"zarrita": "^0.7.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/openlayers"
}
},
"node_modules/osm2streets-js-node": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
"license": "Apache-2.0"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "http://172.16.1.86:4873/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parse-headers": {
"version": "2.0.6",
"resolved": "http://172.16.1.86:4873/parse-headers/-/parse-headers-2.0.6.tgz",
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "http://172.16.1.86:4873/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "http://172.16.1.86:4873/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "http://172.16.1.86:4873/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "http://172.16.1.86:4873/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/rbush": {
"version": "4.0.1",
"resolved": "http://172.16.1.86:4873/rbush/-/rbush-4.0.1.tgz",
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
"license": "MIT",
"dependencies": {
"quickselect": "^3.0.0"
}
},
"node_modules/reference-spec-reader": {
"version": "0.2.0",
"resolved": "http://172.16.1.86:4873/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
"license": "MIT"
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "http://172.16.1.86:4873/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "http://172.16.1.86:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "http://172.16.1.86:4873/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/unzipit": {
"version": "2.0.0",
"resolved": "http://172.16.1.86:4873/unzipit/-/unzipit-2.0.0.tgz",
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "http://172.16.1.86:4873/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "http://172.16.1.86:4873/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"license": "CC0-1.0"
},
"node_modules/zarrita": {
"version": "0.7.4",
"resolved": "http://172.16.1.86:4873/zarrita/-/zarrita-0.7.4.tgz",
"integrity": "sha512-NChufj86SceFj45z4CYjndmI98dphaG7hU8Wj0UGpMyT21o0V3HTY6zo87aSobYWts1tNFivSXzH2AR1AkaWjA==",
"license": "MIT",
"dependencies": {
"@zarrita/storage": "^0.2.0",
"numcodecs": "^0.3.2"
}
},
"node_modules/zstddec": {
"version": "0.2.0",
"resolved": "http://172.16.1.86:4873/zstddec/-/zstddec-0.2.0.tgz",
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
"license": "MIT AND BSD-3-Clause"
}
}
}

View File

@@ -4,21 +4,32 @@
"private": true,
"type": "commonjs",
"scripts": {
"build": "node scripts/build-area.js",
"build": "node scripts/interactive-build.js",
"build:area": "node scripts/build-area.js",
"build:qgis": "node scripts/build-osm2streets-qgis.js",
"check:area": "node scripts/check-area.js",
"compress:glb": "node scripts/compress-glb.js",
"diagnose:area": "node scripts/diagnose-area.js",
"preflight:area": "node scripts/preflight-area.js",
"road:compile": "node scripts/compile-native-roads.js",
"road:check": "node scripts/check-native-roads.js",
"road:workbench": "node scripts/road-workbench.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js",
"test:budgets": "node scripts/test-asset-budgets.js",
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:compress-glb": "node scripts/test-compress-glb.js",
"test:package-contract": "node scripts/test-package-contract.js",
"test:package-examples": "node scripts/test-package-examples.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": {
"@inquirer/prompts": "^8.5.2",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}
}

View File

@@ -4,6 +4,9 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
const { resolveStages } = require("./lib/build-stages");
const { validateManifest, addIntegrity } = require("./lib/package-contract");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
const {
SCENE_LAYERS,
@@ -40,17 +43,12 @@ const requestedStages = args.stages
? splitList(args.stages)
: null;
const stages = resolveStages(area.stages, requestedStages);
// intermediates deletes and rebuilds the GeoPackage from OSM, which is exactly
// the manual work reimport exists to recover. Refuse the combination instead of
// silently letting one undo the other.
if (stages.intermediates && stages.reimport) {
throw new Error(
"Stages 'intermediates' and 'reimport' are mutually exclusive: " +
"intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.",
);
const roadProvider = args.roadProvider || area.blender.roadProvider;
if (!new Set(["osm2streets", "native"]).has(roadProvider)) {
throw new Error("--road-provider must be \"osm2streets\" or \"native\".");
}
console.log(`Area: ${area.id}`);
console.log(`Config: ${configPath}`);
console.log(`Output: ${area.outputs.areaDir}`);
@@ -62,17 +60,20 @@ if (stages.reimport) {
reimportGpkg(area);
}
if (stages.blender) {
buildBlenderScene(area);
buildBlenderScene(area, roadProvider);
}
if (stages.cesium) {
exportCesium(area);
}
if (stages.preview) {
writeCesiumPreview(area);
exportCesium(area, roadProvider);
}
if (stages.compress) {
compressCesiumGlb(area);
}
if (stages.package) {
publishPackage(area);
}
if (stages.preview) {
writeCesiumPreview(area);
}
console.log("Done.");
@@ -100,49 +101,6 @@ function splitList(value) {
.filter(Boolean);
}
function resolveStages(defaults, requested) {
if (!requested) return defaults;
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build.
// 'compress' is also absent from 'all': it creates an alternate Cesium GLB,
// not the baseline asset.
const aliases = {
all: ["intermediates", "blender", "cesium"],
qgis: ["intermediates"],
osm2streets: ["intermediates"],
geojson: ["intermediates"],
intermediate: ["intermediates"],
intermediates: ["intermediates"],
reimport: ["reimport"],
gpkg: ["reimport"],
blender: ["blender"],
scene: ["blender"],
cesium: ["cesium"],
glb: ["cesium"],
preview: ["preview"],
html: ["preview"],
cesiumPreview: ["preview"],
compress: ["compress"],
compression: ["compress"],
compressedCesium: ["compress"],
};
const out = {
intermediates: false,
reimport: false,
blender: false,
cesium: false,
preview: false,
compress: false,
};
for (const stage of requested) {
const mapped = aliases[stage];
if (!mapped) {
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
}
for (const key of mapped) out[key] = true;
}
return out;
}
function writeDerivedConfig(area) {
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
@@ -153,6 +111,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,
@@ -200,6 +159,7 @@ 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),
@@ -207,6 +167,7 @@ function buildIntermediates(area) {
},
summary: {
geojson: geojsonFeatureCounts(area),
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
},
warnings: [],
});
@@ -241,21 +202,33 @@ 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: [],
});
}
function buildBlenderScene(area) {
function buildBlenderScene(area, roadProvider) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
if (roadProvider === "osm2streets") {
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 });
if (roadProvider === "native") {
compileNativeRoads(configPath);
ensureNativeRoadLayers(area);
}
const blenderArgs = [
"--background",
@@ -265,8 +238,6 @@ function buildBlenderScene(area) {
"--",
"--osm",
area.input,
"--geojson",
area.outputs.geojsonDir,
"--output",
area.outputs.blend,
"--render",
@@ -274,6 +245,11 @@ function buildBlenderScene(area) {
"--tree-style",
area.blender.treeStyle,
];
if (roadProvider === "native") {
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
} else {
blenderArgs.push("--geojson", area.outputs.geojsonDir);
}
if (area.blender.officeOverrides) {
blenderArgs.push("--office-overrides", area.blender.officeOverrides);
}
@@ -293,16 +269,20 @@ function buildBlenderScene(area) {
inputs: {
config: fileRecord(configPath),
osm: fileRecord(area.input),
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignals: fileRecord(area.outputs.trafficSignals),
...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)),
...(roadProvider === "osm2streets" ? {
geojsonDir: fileRecord(area.outputs.geojsonDir),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
} : {}),
},
outputs: {
blend: fileRecord(area.outputs.blend),
render: fileRecord(area.outputs.render),
},
summary: {
geojson: geojsonFeatureCounts(area),
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }),
roadProvider,
blendBytes: fileRecord(area.outputs.blend).bytes,
renderBytes: fileRecord(area.outputs.render).bytes,
},
@@ -310,19 +290,60 @@ function buildBlenderScene(area) {
});
}
function exportCesium(area) {
function ensureNativeRoadLayers(area) {
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "direction_arrows.geojson", "turn_arrows.geojson"]) {
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
}
}
function nativeRoadRecords(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
return {
nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")),
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
};
}
function nativeRoadFeatureCounts(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
return {
roadSurface: featureCount(path.join(root, "road_surface.geojson")),
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
};
}
function exportCesium(area, roadProvider) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(area.outputs.blend, "Blend scene");
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
if (roadProvider === "osm2streets") {
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
}
console.log("Stage: cesium");
const started = Date.now();
const startedAt = new Date(started).toISOString();
runCommand(blenderExecutable(area), [
const exporterArgs = [
"--background",
"--factory-startup",
// Blender 4.5 on this macOS host can crash while probing Metal extensions
// before the exporter script runs; this is Blender's documented workaround.
"--debug-gpu-force-workarounds",
"--python",
path.join(repoRoot, "blender", "export_cesium.py"),
"--",
@@ -330,20 +351,23 @@ 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");
];
if (roadProvider === "osm2streets") {
exporterArgs.splice(-2, 0,
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
);
}
runCommand(blenderExecutable(area), exporterArgs, "cesium");
if (roadProvider === "osm2streets") {
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();
const digest = glbDigest(area.outputs.glb);
writeStageManifest(area, {
@@ -359,12 +383,15 @@ function exportCesium(area) {
outputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
...(roadProvider === "osm2streets" ? {
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
} : {}),
semanticAssets,
},
summary: {
glb: glbSummary(digest),
budget: evaluateGlbBudget(digest, area.budget),
roadProvider,
},
warnings: glbBudgetWarnings("Cesium", digest, area.budget),
});
@@ -375,8 +402,8 @@ function semanticAssetRecords(area) {
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
const records = {};
for (const asset of assets) {
if (asset.category !== "semantic") continue;
const file = path.join(area.outputs.areaDir, asset.url);
if (asset.role !== "layer") continue;
const file = path.resolve(area.outputs.packageStagingDir, asset.uri);
ensureFile(file, `Cesium semantic asset '${asset.id}'`);
records[asset.id] = fileRecord(file);
}
@@ -386,75 +413,111 @@ function semanticAssetRecords(area) {
function compressCesiumGlb(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");
ensureFile(area.outputs.cesiumPreview, "Cesium preview");
ensureFile(path.join(repoRoot, "scripts", "compress-glb.js"), "GLB compressor");
fs.mkdirSync(path.dirname(area.outputs.compressedGlb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.compressedMetadata), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.compressedCesiumPreview), { recursive: true });
const compressArgs = [
path.join(repoRoot, "scripts", "compress-glb.js"),
"--input",
area.outputs.glb,
"--output",
area.outputs.compressedGlb,
"--texture-size",
String(area.compress.textureSize),
"--quality",
String(area.compress.quality),
"--effort",
String(area.compress.effort),
"--metadata",
area.outputs.metadata,
"--metadata-output",
area.outputs.compressedMetadata,
"--preview",
area.outputs.cesiumPreview,
"--preview-output",
area.outputs.compressedCesiumPreview,
];
if (area.compress.meshopt) {
compressArgs.push("--meshopt");
}
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
const started = Date.now();
const startedAt = new Date(started).toISOString();
runCommand(process.execPath, compressArgs, "compress");
const finished = Date.now();
const sourceDigest = glbDigest(area.outputs.glb);
const compressedDigest = glbDigest(area.outputs.compressedGlb);
writeStageManifest(area, {
stage: "compress",
status: "ok",
config: configPath,
startedAt,
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
inputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
},
outputs: {
compressedGlb: fileRecord(area.outputs.compressedGlb),
compressedMetadata: fileRecord(area.outputs.compressedMetadata),
compressedCesiumPreview: fileRecord(area.outputs.compressedCesiumPreview),
},
summary: {
sourceGlb: glbSummary(sourceDigest),
compressedGlb: glbSummary(compressedDigest),
budget: evaluateGlbBudget(compressedDigest, area.budget),
options: {
textureSize: area.compress.textureSize,
quality: area.compress.quality,
effort: area.compress.effort,
meshopt: area.compress.meshopt,
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const temporaryDir = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "compress-"));
// The compressor rejects an in-place transform. Preserve the exporter output
// in a temporary directory, then atomically promote the compressed delivery.
const sourceDir = path.join(temporaryDir, "source");
const outputDir = path.join(temporaryDir, "delivery");
const stagedSourceGlb = path.join(sourceDir, path.basename(area.outputs.glb));
const stagedSourceMetadata = path.join(sourceDir, path.basename(area.outputs.metadata));
const stagedDeliveryGlb = path.join(outputDir, path.basename(area.outputs.glb));
const stagedDeliveryMetadata = path.join(outputDir, path.basename(area.outputs.metadata));
try {
fs.mkdirSync(sourceDir, { recursive: true });
fs.copyFileSync(area.outputs.glb, stagedSourceGlb);
fs.copyFileSync(area.outputs.metadata, stagedSourceMetadata);
const sourceDigest = glbDigest(stagedSourceGlb);
const compressArgs = [
path.join(repoRoot, "scripts", "compress-glb.js"),
"--input", stagedSourceGlb,
"--output", stagedDeliveryGlb,
"--texture-size", String(area.compress.textureSize),
"--quality", String(area.compress.quality),
"--effort", String(area.compress.effort),
"--metadata", stagedSourceMetadata,
"--metadata-output", stagedDeliveryMetadata,
];
if (area.compress.meshopt) compressArgs.push("--meshopt");
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
runCommand(process.execPath, compressArgs, "compress");
ensureFile(stagedDeliveryGlb, "Compressed delivery GLB");
ensureFile(stagedDeliveryMetadata, "Compressed delivery metadata");
const compressedDigest = glbDigest(stagedDeliveryGlb);
fs.renameSync(stagedDeliveryGlb, area.outputs.glb);
fs.renameSync(stagedDeliveryMetadata, area.outputs.metadata);
const finished = Date.now();
writeStageManifest(area, {
stage: "compress",
status: "ok",
config: configPath,
startedAt,
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
inputs: {},
// package promotes these staged files, so only its manifest owns final paths.
outputs: {},
summary: {
sourceGlb: glbSummary(sourceDigest),
glb: glbSummary(compressedDigest),
budget: evaluateGlbBudget(compressedDigest, area.budget),
options: {
textureSize: area.compress.textureSize,
quality: area.compress.quality,
effort: area.compress.effort,
meshopt: area.compress.meshopt,
},
compressionRatio: Number((compressedDigest.fileBytes / sourceDigest.fileBytes).toFixed(4)),
savedBytes: sourceDigest.fileBytes - compressedDigest.fileBytes,
},
compressionRatio: Number((compressedDigest.fileBytes / sourceDigest.fileBytes).toFixed(4)),
savedBytes: sourceDigest.fileBytes - compressedDigest.fileBytes,
warnings: glbBudgetWarnings("Compressed", compressedDigest, area.budget),
});
} finally {
fs.rmSync(temporaryDir, { recursive: true, force: true });
}
}
function publishPackage(area) {
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
const started = Date.now();
const manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8"));
fs.mkdirSync(area.outputs.packageStagingRuntimeDir, { recursive: true });
fs.copyFileSync(area.outputs.trafficSignals, area.outputs.packageStagingTrafficSignals);
manifest.runtime = Array.isArray(manifest.runtime) ? manifest.runtime : [];
if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) {
manifest.runtime.push({ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime/traffic-signals.json" });
}
validateManifest(manifest, area.outputs.packageStagingDir);
addIntegrity(manifest, area.outputs.packageStagingDir);
fs.writeFileSync(area.outputs.packageStagingManifest, `${JSON.stringify(manifest, null, 2)}\n`);
validateManifest(manifest, area.outputs.packageStagingDir);
const backup = `${area.outputs.packageDir}.previous`;
fs.rmSync(backup, { recursive: true, force: true });
try {
if (fs.existsSync(area.outputs.packageDir)) fs.renameSync(area.outputs.packageDir, backup);
fs.renameSync(area.outputs.packageStagingDir, area.outputs.packageDir);
fs.rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!fs.existsSync(area.outputs.packageDir) && fs.existsSync(backup)) fs.renameSync(backup, area.outputs.packageDir);
throw error;
}
const finished = Date.now();
writeStageManifest(area, {
stage: "package", status: "ok", config: configPath,
startedAt: new Date(started).toISOString(), finishedAt: new Date(finished).toISOString(), durationMs: finished - started,
inputs: { stagingManifest: fileRecord(path.join(area.outputs.packageDir, "manifest.json")) },
outputs: {
packageDir: fileRecord(area.outputs.packageDir),
manifest: fileRecord(area.outputs.packageManifest),
primaryGlb: fileRecord(area.outputs.packagePrimaryGlb),
},
warnings: glbBudgetWarnings("Compressed", compressedDigest, area.budget),
summary: { assets: manifest.assets.length, packageDir: area.outputs.packageDir }, warnings: [],
});
}
@@ -480,21 +543,31 @@ 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");
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
ensureFile(area.outputs.packageTrafficSignals, "Published 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, previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals)));
const glbName = "package/manifest.json";
const metadataName = "package/manifest.json";
const routeName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute);
const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel);
const descriptor = { routeName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute), vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
fs.mkdirSync(area.outputs.previewDir, { recursive: true });
fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`);
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json"));
console.log(`Cesium preview: ${htmlPath}`);
const finished = Date.now();
writeStageManifest(area, {
@@ -507,8 +580,11 @@ function writeCesiumPreview(area) {
inputs: {
config: fileRecord(configPath),
osm: fileRecord(area.input),
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
glb: fileRecord(area.outputs.packagePrimaryGlb),
metadata: fileRecord(area.outputs.packageManifest),
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")),
},
@@ -516,7 +592,7 @@ function writeCesiumPreview(area) {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel),
trafficSignals: fileRecord(area.outputs.trafficSignals),
trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
},
summary: previewSummary(area),
warnings: [],
@@ -524,10 +600,7 @@ function writeCesiumPreview(area) {
}
function writeTrafficSignals(area) {
const signals = readTrafficSignals(
path.join(area.outputs.geojsonDir, "vehicle_stop_lines.geojson"),
path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
);
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}`);
}
@@ -536,8 +609,7 @@ function previewRelativePath(fromDir, target) {
return path.relative(fromDir, target).split(path.sep).join("/");
}
function writeVehicleRoute(area) {
const route = buildPreviewVehicleRoute(area.input);
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

@@ -7,8 +7,10 @@ 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,
@@ -39,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}`);
@@ -116,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"));
@@ -134,6 +146,7 @@ const ogrEnv = qgis.env;
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];
@@ -149,6 +162,7 @@ fs.writeFileSync(qgisScript, makeQgisScript({
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], {
@@ -1437,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.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)}
@@ -1459,6 +1482,11 @@ try:
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,
@@ -1475,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()
@@ -1495,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}")

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea, parseArgs } = require("./compile-native-roads");
const repoRoot = path.resolve(__dirname, "..");
function checkArea(configPath, options = {}) {
if (options.compile) compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot });
const root = area.outputs.nativeRoadDir;
const compiledPath = path.join(root, "compiled.json");
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
const compiled = readJson(compiledPath);
const connectors = readJson(path.join(root, "layers", "connectors.geojson"));
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
const failures = [];
for (const movement of compiled.movements || []) {
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`);
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`);
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
}
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error");
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning");
return { schema: "native-road-check/v1", areaId: area.id, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures };
}
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
const result = checkArea(configPath, { compile: args.compile === "true" });
console.log(`NATIVE_ROAD_CHECK_DONE ${JSON.stringify(result)}`);
if (!result.ok) process.exitCode = 1;
}
if (require.main === module) main();
module.exports = { checkArea };

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const repoRoot = path.resolve(__dirname, "..");
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
if (!argv[index].startsWith("--")) continue;
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
}
return result;
}
function compileArea(configPath) {
const area = readAreaConfig(configPath, { repoRoot });
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
validateOverrides(overrides, model);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const compiled = compileGeometry(model, overrides);
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
try {
const result = {
schema: "native-road-compiled/v1",
areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements,
diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", connectors: "layers/connectors.geojson" },
};
const comparison = compareOsm2Streets(area, result.model, compiled);
writeJsonAtomic(path.join(staging, "compiled.json"), result);
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
return { area, result, comparison };
} catch (error) {
fs.rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function compareOsm2Streets(area, model, compiled) {
const source = path.join(area.outputs.geojsonDir, "road_surface.geojson");
let featureCount = null;
if (fs.existsSync(source)) {
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
}
const diagnosticsBySeverity = {};
const diagnosticsByRule = {};
for (const item of compiled.diagnostics) {
diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1;
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
}
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
const junctions = compiled.intersectionSurface.features;
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
return {
schema: "native-road-comparison/v2",
nativeRoadCount: model.roads.length,
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
nativeFallbackJunctions: fallbackJunctions.length,
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeConnectorFeatures: compiled.connectors.features.length,
nativeMovementCount: compiled.movements.length,
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
nativeConnectionCount: model.connections.length,
unconnectedInteriorRoadEnds: dangling.length,
unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length,
diagnosticsBySeverity,
diagnosticsByRule,
osm2streetsRoadSurfaceFeatures: featureCount,
osm2streetsAvailable: featureCount !== null,
note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.",
};
}
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
const { area, result, comparison } = compileArea(configPath);
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
}
if (require.main === module) main();
module.exports = { compileArea, parseArgs };

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
"use strict";
// Experimental GLB compression wrapper. Keeps the source GLB intact and writes
// a separate compressed artifact for visual comparison in Cesium.
// GLB compression wrapper. The caller supplies distinct input and output files;
// build-area.js uses staging files before promoting the compressed delivery.
const fs = require("fs");
const os = require("os");
@@ -143,30 +143,25 @@ function previewOutputPath(output, explicitPath) {
function writeCompressedMetadata(sourceMetadata, outputGlb, outputMetadata) {
const metadata = JSON.parse(fs.readFileSync(sourceMetadata, "utf8"));
const glbName = path.basename(outputGlb);
metadata.asset = glbName;
if (metadata.asset !== undefined) metadata.asset = glbName;
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
const main = assets.find((asset) => asset.id === "main");
metadata.assets = assets.length
? assets.map((asset) => asset.id === "main" ? {
...asset,
label: "Compressed Scene",
url: glbName,
enabled: true,
...(Object.hasOwn(asset, "uri") ? { uri: `models/${glbName}`, defaultLoad: true } : { url: glbName, enabled: true }),
} : asset)
: [{
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: true,
}];
if (!main && assets.length) {
metadata.assets.unshift({
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: true,
});
}
if (typeof metadata.cesium_js === "string") {

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { STAGES, resolveStages, canonicalStages } = require("./lib/build-stages");
const repoRoot = path.resolve(__dirname, "..");
function areaConfigs() {
return fs.readdirSync(path.join(repoRoot, "config", "areas"))
.filter((file) => file.endsWith(".json"))
.sort()
.map((file) => path.join(repoRoot, "config", "areas", file));
}
function requireTty(input = process.stdin, output = process.stdout) {
if (!input.isTTY || !output.isTTY) throw new Error("Interactive build requires a TTY. Use npm run build:area -- --config <area> --stages <stages> instead.");
}
async function main() {
requireTty();
const configs = areaConfigs();
if (!configs.length) throw new Error("No area configs found in config/areas.");
const { select, checkbox } = await import("@inquirer/prompts");
const config = await select({
message: "Choose an area",
choices: configs.map((file) => ({ name: path.basename(file, ".json"), value: file })),
});
const chosen = await checkbox({
message: "Choose build stages",
choices: STAGES.map((stage) => ({ name: `${stage.id} - ${stage.description}`, value: stage.id })),
required: true,
});
if (!chosen.length) throw new Error("Choose at least one build stage.");
const stages = canonicalStages(resolveStages({}, chosen));
console.log(`Area: ${path.basename(config)}`);
console.log(`Stages: ${stages.join(", ")}`);
const result = spawnSync(process.execPath, [path.join(__dirname, "build-area.js"), "--config", config, "--stages", stages.join(",")], { stdio: "inherit" });
if (result.error) throw result.error;
process.exitCode = result.status || 0;
}
if (require.main === module) main().catch((error) => {
if (error.name !== "ExitPromptError") console.error(`Error: ${error.message}`);
process.exitCode = error.name === "ExitPromptError" ? 0 : 1;
});
module.exports = { areaConfigs, requireTty };

View File

@@ -25,41 +25,53 @@ function normalizeAreaConfig(raw, options = {}) {
const fileStem = outputOverrides.fileStem || id;
const compress = normalizeCompressConfig(raw.compress);
const budget = normalizeBudgetConfig(raw.budget);
const compressedFileStem = outputOverrides.compressedFileStem ||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
const packageDir = path.resolve(outputOverrides.packageDir || path.join(areaDir, "package"));
const packageStagingDir = path.resolve(outputOverrides.packageStagingDir || path.join(pipelineDir, "package-staging"));
const packageRuntimeDir = path.resolve(outputOverrides.packageRuntimeDir || path.join(packageDir, "runtime"));
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const nativeRoadDir = path.resolve(outputOverrides.nativeRoadDir || path.join(areaDir, "native-road"));
const outputs = {
areaDir,
geojsonDir,
nativeRoadDir,
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
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`)),
// Published static assets are staged first and promoted by the package stage.
packageDir,
packageStagingDir,
packageManifest: path.resolve(outputOverrides.packageManifest || path.join(packageDir, "manifest.json")),
packageStagingManifest: path.resolve(outputOverrides.packageStagingManifest || path.join(packageStagingDir, "manifest.json")),
packageModelDir: path.resolve(outputOverrides.packageModelDir || path.join(packageDir, "models")),
packageStagingModelDir: path.resolve(outputOverrides.packageStagingModelDir || path.join(packageStagingDir, "models")),
packagePrimaryGlb: path.resolve(outputOverrides.packagePrimaryGlb || path.join(packageDir, "models", `${fileStem}.glb`)),
glb: path.resolve(outputOverrides.glb || path.join(packageStagingDir, "models", `${fileStem}.glb`)),
packageRuntimeDir,
packageStagingRuntimeDir,
trafficSignalsDynamicGlb: path.resolve(outputOverrides.trafficSignalsDynamicGlb || path.join(packageStagingRuntimeDir, "traffic-signals-dynamic.glb")),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(packageStagingRuntimeDir, "traffic-signals-countdown-0.glb")),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(packageStagingRuntimeDir, "traffic-signals-countdown-1.glb")),
packageTrafficSignals: path.resolve(outputOverrides.packageTrafficSignals || path.join(packageRuntimeDir, "traffic-signals.json")),
packageStagingTrafficSignals: path.resolve(outputOverrides.packageStagingTrafficSignals || path.join(packageStagingRuntimeDir, "traffic-signals.json")),
metadata: path.resolve(outputOverrides.metadata || path.join(packageStagingDir, "manifest.json")),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
),
compressedGlb: path.resolve(
outputOverrides.compressedGlb || path.join(areaDir, `${compressedFileStem}.glb`),
previewDir,
previewDescriptor: path.resolve(outputOverrides.previewDescriptor || path.join(previewDir, "descriptor.json")),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(previewDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(previewDir, `${fileStem}-vehicle-car.gltf`)),
trafficSignalAssemblies: path.resolve(
outputOverrides.trafficSignalAssemblies || path.join(geojsonDir, "traffic_signal_assemblies.geojson"),
),
compressedMetadata: path.resolve(
outputOverrides.compressedMetadata || path.join(areaDir, `${compressedFileStem}.json`),
),
compressedCesiumPreview: path.resolve(
outputOverrides.compressedCesiumPreview || path.join(areaDir, `${compressedFileStem}-cesium-preview.html`),
),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
// Signals are an auxiliary intermediates artifact shared by Blender and
// the browser preview. They deliberately are not one of the QGIS layers.
// 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")),
@@ -76,8 +88,9 @@ function normalizeAreaConfig(raw, options = {}) {
blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true,
reimport: false,
preview: false,
compress: false,
preview: true,
compress: true,
package: true,
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
@@ -104,6 +117,7 @@ function normalizeAreaConfig(raw, options = {}) {
blender: {
treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
roadProvider: roadProviderOption(raw.blender?.roadProvider),
},
compress,
budget,
@@ -159,6 +173,14 @@ function numberOption(value, fallback, label, min, max) {
return number;
}
function roadProviderOption(value) {
const provider = value ?? "osm2streets";
if (provider !== "osm2streets" && provider !== "native") {
throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\".");
}
return provider;
}
function integerOption(value, fallback, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isInteger(number) || number < 1) {

View File

@@ -10,6 +10,7 @@ const {
layerFile,
} = require("./scene-layers");
const { digest: glbDigest } = require("../glb-digest");
const { validateManifest } = require("./package-contract");
const {
BUDGETS,
evaluateGlbBudget,
@@ -31,8 +32,8 @@ function analyzeArea(configPath, options = {}) {
const artifacts = artifactStatus(area);
const manifests = stageManifestStatus(area, resolvedConfig);
const metadataWarnings = [];
const metadata = metadataSummary(area.outputs.metadata, metadataWarnings);
const glb = fs.existsSync(area.outputs.glb) ? glbDigest(area.outputs.glb) : null;
const metadata = metadataSummary(area.outputs.packageManifest, metadataWarnings);
const glb = fs.existsSync(area.outputs.packagePrimaryGlb) ? glbDigest(area.outputs.packagePrimaryGlb) : null;
const warnings = [
...metadataWarnings,
...collectWarnings(area, osm, artifacts, manifests, glb, metadata),
@@ -327,14 +328,13 @@ 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.packageTrafficSignals, true, "file"],
["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"],
["Cesium GLB", area.outputs.glb, true, "file"],
["Cesium metadata", area.outputs.metadata, true, "file"],
["Package manifest", area.outputs.packageManifest, true, "file"],
["Package primary GLB", area.outputs.packagePrimaryGlb, true, "file"],
["Cesium preview", area.outputs.cesiumPreview, true, "file"],
["Compressed GLB", area.outputs.compressedGlb, false, "file"],
["Compressed metadata", area.outputs.compressedMetadata, false, "file"],
["Compressed preview", area.outputs.compressedCesiumPreview, false, "file"],
];
return entries.map(([label, file, expected, type]) => {
const exists = fs.existsSync(file);
@@ -360,6 +360,7 @@ function metadataSummary(file, warnings) {
if (!fs.existsSync(file)) return null;
try {
const metadata = JSON.parse(fs.readFileSync(file, "utf8"));
if (metadata.schema === "osm-asset-package/v1") validateManifest(metadata, path.dirname(file));
return {
asset: metadata.asset || null,
assets: Array.isArray(metadata.assets) ? metadata.assets.length : 0,
@@ -372,6 +373,7 @@ function metadataSummary(file, warnings) {
}
function stageManifestStatus(area, configPath = null) {
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
const reimportManifest = stageManifestPath(area, "reimport");
const hasReimportManifest = fs.existsSync(reimportManifest);
const derivedConfig = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json");
@@ -400,6 +402,8 @@ function stageManifestStatus(area, configPath = null) {
derivedConfig,
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.packageTrafficSignals,
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.packageTrafficSignals,
},
},
{
@@ -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.packageTrafficSignals,
},
outputs: {
blend: area.outputs.blend,
@@ -438,7 +446,7 @@ function stageManifestStatus(area, configPath = null) {
inputs: {
blend: area.outputs.blend,
},
outputs: {
outputs: compressionComplete ? {} : {
glb: area.outputs.glb,
metadata: area.outputs.metadata,
},
@@ -449,12 +457,20 @@ function stageManifestStatus(area, configPath = null) {
inputs: {
...(configPath ? { config: configPath } : {}),
osm: area.input,
glb: area.outputs.glb,
metadata: area.outputs.metadata,
...(compressionComplete ? {} : {
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"),
},
outputs: {
outputs: compressionComplete ? {
vehicleRoute: area.outputs.vehicleRoute,
vehicleModel: area.outputs.vehicleModel,
} : {
cesiumPreview: area.outputs.cesiumPreview,
vehicleRoute: area.outputs.vehicleRoute,
vehicleModel: area.outputs.vehicleModel,
@@ -462,16 +478,18 @@ function stageManifestStatus(area, configPath = null) {
},
{
stage: "compress",
expected: fs.existsSync(area.outputs.compressedGlb),
inputs: {
glb: area.outputs.glb,
metadata: area.outputs.metadata,
cesiumPreview: area.outputs.cesiumPreview,
},
expected: fs.existsSync(stageManifestPath(area, "compress")),
inputs: {},
outputs: {},
},
{
stage: "package",
expected: fs.existsSync(area.outputs.packageManifest),
inputs: {},
outputs: {
compressedGlb: area.outputs.compressedGlb,
compressedMetadata: area.outputs.compressedMetadata,
compressedCesiumPreview: area.outputs.compressedCesiumPreview,
packageDir: area.outputs.packageDir,
manifest: area.outputs.packageManifest,
primaryGlb: area.outputs.packagePrimaryGlb,
},
},
];
@@ -738,7 +756,7 @@ function classifyAreaQuality(result) {
warnings.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`);
}
const fatalArtifacts = new Set(["Cesium GLB", "Cesium metadata", "Cesium preview"]);
const fatalArtifacts = new Set(["Package manifest", "Package primary GLB", "Cesium preview"]);
for (const artifact of artifacts) {
if (fatalArtifacts.has(artifact.label)) {
if (!artifact.exists) {

View File

@@ -4,8 +4,13 @@ const fs = require("fs");
const path = require("path");
function writeCesiumPreviewSupportFiles(outDir) {
for (const file of ["cesium-preview.css", "cesium-preview.js"]) {
const source = path.join(__dirname, file);
const files = [
[path.join(__dirname, "cesium-preview.css"), "cesium-preview.css"],
[path.join(__dirname, "cesium-preview.js"), "cesium-preview.js"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-breakdown.png"), "vehicle-breakdown.png"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-accident.png"), "vehicle-accident.png"],
];
for (const [source, file] of files) {
if (!fs.existsSync(source)) {
throw new Error(`Cesium preview support file '${file}' not found: ${source}`);
}
@@ -13,7 +18,7 @@ function writeCesiumPreviewSupportFiles(outDir) {
}
}
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) {
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null, previewDescriptorName = null) {
const previewConfig = {
areaId,
glbName,
@@ -22,6 +27,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
vehicleModelName,
vehicleModelNames,
trafficSignalsName,
previewDescriptorName,
};
return `<!doctype html>
<html lang="zh-CN">
@@ -50,6 +56,7 @@ 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>
@@ -67,6 +74,21 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
</div>
<div id="diagnostics">Loading diagnostics...</div>
<div id="status">Loading ${escapeHtml(glbName)}...</div>
<aside id="vehicleInfoCard" class="hidden" aria-live="polite">
<div class="vehicle-card-heading">
<strong id="vehicleInfoTitle">Vehicle</strong>
<button id="closeVehicleInfo" type="button" aria-label="Close vehicle information">Close</button>
</div>
<dl id="vehicleInfoDetails"></dl>
<label class="vehicle-card-field">Event note
<textarea id="vehicleIncidentNote" rows="2" maxlength="160" placeholder="Optional note"></textarea>
</label>
<div class="vehicle-card-actions" role="group" aria-label="Vehicle status">
<button type="button" data-vehicle-status="normal">Normal</button>
<button type="button" data-vehicle-status="breakdown">Breakdown</button>
<button type="button" data-vehicle-status="accident">Accident</button>
</div>
</aside>
<div id="loadingOverlay">
<div class="loading-card">
<div class="loading-spinner" aria-hidden="true"></div>
@@ -104,11 +126,11 @@ function escapeScriptJson(value) {
function previewSummary(area) {
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
return {
glbName: path.basename(area.outputs.glb),
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("/"),
glbName: "package/manifest.json",
metadataName: "package/manifest.json",
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
};
}

View File

@@ -0,0 +1,43 @@
"use strict";
const STAGES = [
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
{ id: "compress", label: "Compress staged package", description: "Compress the staged primary GLB" },
{ id: "package", label: "Publish asset package", description: "Validate and atomically publish package/" },
{ id: "preview", label: "Preview refresh", description: "Regenerate local verification preview and dynamic assets" },
];
const ALIASES = {
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
compress: ["compress"], compression: ["compress"], compressedCesium: ["compress"],
package: ["package"], publish: ["package"], assetPackage: ["package"],
};
function resolveStages(defaults, requested) {
const selected = requested || Object.keys(defaults).filter((key) => defaults[key]);
const result = Object.fromEntries(STAGES.map(({ id }) => [id, false]));
for (const stage of selected) {
const mapped = ALIASES[stage];
if (!mapped) throw new Error(`Unknown stage '${stage}'. Use ${stageNames().join(", ")}, or all.`);
for (const id of mapped) result[id] = true;
}
assertCompatible(result);
return result;
}
function assertCompatible(stages) {
if (stages.intermediates && stages.reimport) {
throw new Error("Stages 'intermediates' and 'reimport' are mutually exclusive: intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.");
}
}
function stageNames() { return STAGES.map(({ id }) => id); }
function canonicalStages(stages) { return stageNames().filter((id) => stages[id]); }
module.exports = { STAGES, resolveStages, canonicalStages, stageNames };

View File

@@ -20,7 +20,8 @@ body.scene-ready #cesiumContainer {
#controls,
#diagnostics,
#status {
#status,
#vehicleInfoCard {
position: absolute;
z-index: 1;
border-radius: 4px;
@@ -160,6 +161,96 @@ body.scene-error .loading-bar span {
white-space: pre-line;
}
#vehicleInfoCard {
left: 0;
top: 0;
width: min(300px, calc(100vw - 24px));
padding: 10px 12px;
transform: translate(-50%, calc(-100% - 18px));
transform-origin: bottom center;
pointer-events: auto;
}
#vehicleInfoCard::after {
position: absolute;
left: 50%;
bottom: -7px;
width: 12px;
height: 12px;
content: "";
transform: translateX(-50%) rotate(45deg);
background: rgba(20, 24, 28, 0.82);
}
.vehicle-card-heading,
.vehicle-card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vehicle-card-heading strong {
font-size: 13px;
}
#vehicleInfoCard button {
height: 27px;
border: 0;
border-radius: 4px;
padding: 0 8px;
background: #f2f5f7;
color: #111;
cursor: pointer;
}
#vehicleInfoDetails {
display: grid;
grid-template-columns: 88px 1fr;
gap: 4px 8px;
margin: 10px 0;
}
#vehicleInfoDetails dt {
color: rgba(255, 255, 255, 0.62);
}
#vehicleInfoDetails dd {
margin: 0;
overflow-wrap: anywhere;
}
.vehicle-card-field {
display: grid;
gap: 4px;
color: rgba(255, 255, 255, 0.78);
}
#vehicleIncidentNote {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid rgba(255, 255, 255, 0.30);
border-radius: 4px;
padding: 6px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font: inherit;
}
.vehicle-card-actions {
justify-content: flex-start;
margin-top: 10px;
}
.vehicle-card-actions button[data-vehicle-status="breakdown"] {
background: #f3c84b;
}
.vehicle-card-actions button[data-vehicle-status="accident"] {
background: #e95950;
}
#controls button,
#controls select {
height: 28px;
@@ -285,4 +376,5 @@ body.scene-error .loading-bar span {
bottom: 10px;
max-width: none;
}
}

View File

@@ -8,6 +8,7 @@
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");
@@ -15,6 +16,12 @@
const toggleFps = document.getElementById("toggleFps");
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
const assetToggles = document.getElementById("assetToggles");
const vehicleInfoCard = document.getElementById("vehicleInfoCard");
const vehicleInfoTitle = document.getElementById("vehicleInfoTitle");
const vehicleInfoDetails = document.getElementById("vehicleInfoDetails");
const vehicleIncidentNote = document.getElementById("vehicleIncidentNote");
const closeVehicleInfo = document.getElementById("closeVehicleInfo");
const vehicleStatusButtons = Array.from(document.querySelectorAll("[data-vehicle-status]"));
// 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.
@@ -39,6 +46,8 @@
async function main() {
setLoadingMessage("Loading scene", config.areaId || "");
const metadata = await fetchJson(config.metadataName);
const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null;
adaptPackageManifest(metadata, descriptor);
const routeData = await fetchOptionalJson(config.routeName);
const signalData = await fetchOptionalJson(config.trafficSignalsName);
const placement = scenePlacement(metadata);
@@ -53,6 +62,7 @@
buildAssetToggles(assets);
buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
bindVehicleInfoCard(viewer, cruise);
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
@@ -66,7 +76,9 @@
}
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);
}
@@ -142,11 +154,11 @@
}
function scenePlacement(metadata) {
const anchor = metadata.anchor || {};
const anchor = metadata.anchor || metadata.placement || {};
const longitude = Number(anchor.longitude || 0);
const latitude = Number(anchor.latitude || 0);
const height = Number(anchor.height || 0);
const heading = Number(metadata.heading_correction_degrees || 0);
const heading = Number(metadata.heading_correction_degrees ?? metadata.placement?.headingCorrectionDegrees ?? 0);
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
@@ -161,6 +173,28 @@
}
function normalizedAssets(metadata) {
if (metadata.schema === "osm-asset-package/v1") {
const staticAssets = metadata.assets.map((asset) => ({
id: asset.id,
label: asset.id,
type: "model",
// Package URIs are relative to manifest.json, not the preview HTML.
url: new URL(asset.uri, new URL(config.metadataName, window.location.href)).href,
enabled: asset.defaultLoad,
category: asset.category,
semantic: asset.role === "layer",
}));
const runtimeAssets = (metadata.runtime || []).filter((asset) => asset.type !== "traffic-signal-anchors").map((asset) => ({
id: asset.id,
label: asset.id,
type: "model",
url: new URL(asset.uri, new URL(config.metadataName, window.location.href)).href,
enabled: true,
category: asset.type === "traffic-signal-lenses" ? "dynamic" : asset.type === "traffic-signal-countdown" ? "countdown" : asset.type,
phaseGroup: asset.phaseGroup,
}));
return staticAssets.concat(runtimeAssets, metadata.previewAssets || []);
}
const assets = Array.isArray(metadata.assets) && metadata.assets.length
? metadata.assets
: [{
@@ -173,6 +207,15 @@
return assets.filter((asset) => (asset.type || "model") === "model" && asset.url);
}
function adaptPackageManifest(metadata, descriptor) {
if (metadata.schema !== "osm-asset-package/v1") return;
metadata.anchor = metadata.placement;
metadata.heading_correction_degrees = metadata.placement.headingCorrectionDegrees;
metadata.scene_stats = metadata.sceneStats || {};
metadata.bounds = { min_lon: metadata.bounds.minLon, min_lat: metadata.bounds.minLat, max_lon: metadata.bounds.maxLon, max_lat: metadata.bounds.maxLat };
if (descriptor?.assets) metadata.previewAssets = descriptor.assets;
}
// One broken entry in metadata.assets should not blank the whole preview, so
// failures are collected and surfaced in the diagnostics panel instead.
async function loadSceneAssets(viewer, metadata, placement) {
@@ -184,7 +227,7 @@
loaded.push(entry);
// Semantic assets are inspection aids. Defer their network and GPU cost
// until the user explicitly switches out of the normal scene view.
if (entry.category === "semantic") continue;
if (entry.semantic || entry.enabled === false) continue;
await loadAsset(viewer, entry, placement);
}
if (!loaded.some((asset) => asset.model && asset.category !== "semantic")) {
@@ -287,8 +330,60 @@
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;
@@ -297,7 +392,7 @@
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;
@@ -316,6 +411,14 @@
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;
@@ -329,6 +432,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;
}
@@ -418,6 +525,7 @@
});
vehicleSelect.addEventListener("change", () => {
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
syncSelectedRouteVisibility(cruise);
setStatus(selectedVehicle(cruise).label);
});
@@ -462,11 +570,19 @@
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) {
@@ -482,6 +598,7 @@
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;
@@ -496,16 +613,29 @@
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_${signal.id}_${state}`);
const value = node(`TrafficSignalDynamic_${nodeKey}_${state}`);
if (value && value.show !== (state === phase.active)) {
value.show = state === phase.active;
changed = true;
@@ -514,9 +644,9 @@
const visibleCountdown = String(phase.remaining).padStart(2, "0");
const countdownModel = countdownModels.get(Number(signal.phaseGroup));
for (let value = 0; value < 20; value += 1) {
const name = `TrafficSignalDynamic_${signal.id}_countdown_${String(value).padStart(2, "0")}`;
const name = `TrafficSignalDynamic_${nodeKey}_countdown_${String(value).padStart(2, "0")}`;
let countdown = null;
try { countdown = countdownModel.getNode(name); } catch (error) { /* model node table is still loading */ }
countdown = countdownNode(countdownModel, name);
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
countdown.show = String(value).padStart(2, "0") === visibleCountdown;
changed = true;
@@ -544,6 +674,13 @@
// 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) {
@@ -596,7 +733,8 @@
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed);
let record = null;
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
@@ -631,14 +769,41 @@
minimumPixelSize: 0
}
});
return {
record = {
id: "vehicle-" + (index + 1),
entity: vehicle,
routeEntity,
positions,
route,
segment,
label: routeLabel(segment, index),
modelName: vehicleModelLabel(vehicleModelName),
status: "normal",
incidentNote: "",
motion: trafficMotion,
routeColor,
markerEntity: null,
};
vehicle.properties = new Cesium.PropertyBag({ vehicleId: record.id });
record.markerEntity = viewer.entities.add({
name: "Vehicle incident marker " + (index + 1),
position: positions,
billboard: {
image: "",
show: false,
width: 36,
height: 44,
pixelOffset: new Cesium.Cartesian2(0, -40),
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
return record;
}
function vehicleModelLabel(name) {
const file = String(name || "").split("/").pop().replace(/\.gltf$/i, "");
return file.replace(/^.*-vehicle-/, "").replaceAll("_", " ");
}
function routeLabel(route, index) {
@@ -659,6 +824,96 @@
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
}
function vehicleById(cruise, id) {
return cruise.vehicles.find((vehicle) => vehicle.id === id) || null;
}
function setVehicleStatus(viewer, vehicle, status) {
if (!vehicle || !["normal", "breakdown", "accident"].includes(status)) return;
vehicle.status = status;
const incident = status !== "normal";
const accident = status === "accident";
vehicle.markerEntity.billboard.image = status === "breakdown"
? "vehicle-breakdown.png"
: accident ? "vehicle-accident.png" : "";
vehicle.markerEntity.billboard.show = incident;
vehicle.routeEntity.polyline.material = accident ? Cesium.Color.RED : vehicle.routeColor;
if (viewer.scene.requestRender) viewer.scene.requestRender();
}
function bindVehicleInfoCard(viewer, cruise) {
if (!vehicleInfoCard || !cruise.vehicles.length) return;
let activeVehicle = null;
const render = () => {
if (!activeVehicle) return;
vehicleInfoTitle.textContent = activeVehicle.label;
vehicleIncidentNote.value = activeVehicle.incidentNote;
const details = [
["Vehicle", activeVehicle.id],
["Model", activeVehicle.modelName],
["Route", activeVehicle.label],
["Speed", cruise.baseSpeed + " m/s"],
["Status", activeVehicle.status],
];
vehicleInfoDetails.replaceChildren(...details.flatMap(([term, value]) => {
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = value;
return [dt, dd];
}));
for (const button of vehicleStatusButtons) {
button.disabled = button.dataset.vehicleStatus === activeVehicle.status;
}
};
const open = (vehicle) => {
activeVehicle = vehicle;
const index = cruise.vehicles.indexOf(vehicle);
cruise.state.selectedIndex = index;
vehicleSelect.value = String(index);
syncSelectedRouteVisibility(cruise);
vehicleInfoCard.classList.remove("hidden");
render();
};
const positionCard = () => {
if (!activeVehicle || vehicleInfoCard.classList.contains("hidden")) return;
const position = activeVehicle.entity.position?.getValue(viewer.clock.currentTime);
const windowPosition = position && Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, position);
if (!windowPosition) {
vehicleInfoCard.classList.add("hidden");
return;
}
const margin = 12;
const width = vehicleInfoCard.offsetWidth || 300;
const x = Cesium.Math.clamp(windowPosition.x, margin + width / 2, window.innerWidth - margin - width / 2);
vehicleInfoCard.style.left = x + "px";
vehicleInfoCard.style.top = Math.max(windowPosition.y, margin + 20) + "px";
};
closeVehicleInfo.addEventListener("click", () => {
activeVehicle = null;
vehicleInfoCard.classList.add("hidden");
});
vehicleIncidentNote.addEventListener("input", () => {
if (activeVehicle) activeVehicle.incidentNote = vehicleIncidentNote.value.trim();
});
for (const button of vehicleStatusButtons) {
button.addEventListener("click", () => {
if (!activeVehicle) return;
setVehicleStatus(viewer, activeVehicle, button.dataset.vehicleStatus);
render();
setStatus(activeVehicle.label + " " + activeVehicle.status);
});
}
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
const picked = viewer.scene.pick(movement.position);
const id = picked?.id?.properties?.vehicleId?.getValue?.();
const vehicle = vehicleById(cruise, id);
if (vehicle) open(vehicle);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
viewer.scene.postRender.addEventListener(positionCard);
}
function prepareRoute(segment, signalData) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
@@ -690,12 +945,13 @@
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficAwarePositions(viewer, route, start, speed) {
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
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;
if (!isMoving()) return;
const next = nextRouteStop(route, state.distance);
const advance = elapsed * speed;
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {

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

743
scripts/lib/native-road.js Normal file
View File

@@ -0,0 +1,743 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
function parseOsmRoads(xml) {
const nodes = new Map();
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
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)) nodes.set(String(attrs.id), coordinate);
}
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 = parseTags(body);
if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue;
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
if (coords.length < 2 || coords.length !== refs.length) continue;
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
}
return { nodes, ways };
}
function compileRoadModel(xml, overrides) {
const parsed = parseOsmRoads(xml);
const diagnostics = [];
const roads = [];
const endpoints = [];
const byNode = new Map();
const sharedNodeWayIds = new Map();
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
sharedNodeWayIds.get(nodeId).add(way.id);
}
for (const sourceWay of parsed.ways) {
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
for (const way of segments) {
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
for (const direction of directions) {
const base = roadAttributes(way.tags, direction);
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
applyRoadOverrides(road, overrides, diagnostics);
roads.push(road);
for (const side of ["start", "end"]) {
const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction };
endpoints.push(endpoint);
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
byNode.get(nodeId).push(endpoint);
}
}
}
}
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
const extent = roadExtent(roads);
for (const [nodeId, items] of byNode) {
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
const endpoint = items[0];
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) });
}
}
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
}
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
const splitIndexes = [0];
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
splitIndexes.push(way.refs.length - 1);
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
return splitIndexes.slice(1).map((end, index) => {
const start = splitIndexes[index];
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
});
}
function roadExtent(roads) {
const points = roads.flatMap((road) => road.centerline);
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
}
function distanceToExtentEdgeMeters(point, extent) {
const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180);
return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320);
}
function roadAttributes(tags, direction) {
const directional = direction === "forward" ? "forward" : "backward";
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null);
const parsedLanes = positiveInteger(laneTag);
const totalLanes = positiveInteger(tags.lanes);
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1);
const parsedWidth = positiveNumber(tags.width);
const forwardLanes = positiveInteger(tags["lanes:forward"]);
const backwardLanes = positiveInteger(tags["lanes:backward"]);
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
// `width` describes the whole OSM way. A directional road receives its lane
// share; absent width falls back to a realistic per-lane carriageway width.
const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25;
return {
laneCount: lanes,
widthMeters: width,
sidewalkLeft: sidewalkState(tags, direction, "left"),
sidewalkRight: sidewalkState(tags, direction, "right"),
provenance: {
laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes",
widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane",
},
};
}
function sidewalkState(tags, direction, side) {
const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left";
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
return value === "both" || value === "yes" || value === osmSide;
}
function loadOverrides(file) {
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
}
function validateOverrides(value, model) {
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
const ids = new Set();
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
for (const item of value.overrides) {
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
ids.add(item.id);
if (item.kind === "road") {
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
} else if (item.kind === "junction-connection") {
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
} else if (item.kind === "lane-connection") {
if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override.");
} else throw new Error(`Unsupported override kind: ${item.kind}`);
}
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
}
function applyRoadOverrides(road, overrides, diagnostics) {
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
// A legacy whole-way edit remains the baseline; a segment-specific edit can
// deliberately refine it after the compiler has introduced split segments.
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
for (const item of matching) {
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
road.appliedOverrideIds.push(item.id);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
}
if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0]));
}
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
const result = [];
for (const [nodeId, items] of byNode) {
const arrivals = items.filter((endpoint) => endpoint.side === "end");
const departures = items.filter((endpoint) => endpoint.side === "start");
for (const arrival of arrivals) for (const departure of departures) {
if (arrival.roadId === departure.roadId) continue;
const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
}
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
}
// Overrides can add a deliberate movement omitted by the initial inference.
// Keep it only when both endpoints still belong to the same OSM junction.
for (const override of overrides.overrides.filter((item) => item.kind === "junction-connection")) {
const exists = result.some((connection) => connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId);
if (exists) continue;
const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
result.push({ id: `connection:${from.id}:${to.id}`, nodeId: from.nodeId, fromEndpointId: from.id, toEndpointId: to.id, enabled: override.enabled, provenance: `override:${override.id}` });
}
return result;
}
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
}
function connectionEndpointsCompatible(model, fromId, toId) {
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
const to = model.endpoints.find((endpoint) => endpoint.id === toId);
if (!from || !to || from.roadId === to.roadId || sameOsmWay(model.endpoints, from.roadId, to.roadId) || from.side !== "end" || to.side !== "start") return false;
if (from.nodeId === to.nodeId) return true;
const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180);
const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
return Math.hypot(dx, dy) <= 35;
}
function nearbyManualCandidates(endpoints, from) {
return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 }));
}
function compileGeometry(model, overrides = { overrides: [] }) {
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model);
const features = [];
const emittedSegments = new Set();
for (const road of model.roads) {
const segmentKey = road.segmentId;
if (emittedSegments.has(segmentKey)) continue;
emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
// Road and junction asphalt share one final material. Keep the carriageway
// continuous through the semantic junction overlay; cutting it back creates
// visible wedges/gaps without improving the rendered result.
const ring = roadRing(road.centerline, totalWidth);
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
const separators = []; const directionArrows = []; const turnArrows = [];
for (const road of model.roads) {
const roadLanes = lanes.byRoadId.get(road.id) || [];
for (let index = 1; index < roadLanes.length; index += 1) {
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
if (left.length !== right.length) continue;
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const ring = roadRing(centerline, 0.12);
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane));
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
const maneuvers = turns ? String(turns).split("|") : [];
for (let index = 0; index < roadLanes.length; index += 1) {
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
if (!explicitManeuver) continue;
const maneuver = normalizeManeuver(explicitManeuver);
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
const center = pointAlongLine([...lane.coordinates].reverse(), 6);
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
const rings = axis ? arrowRingsAt(maneuver, center, axis) : [];
if (!rings.length) continue;
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: 6, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
}
return { separators, directionArrows, turnArrows };
}
function directionArrowFeatures(road, lane) {
const length = lineLengthMeters(lane.coordinates);
const features = [];
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
if (!placement) continue;
const rings = arrowRingsAt("through", placement.point, placement.axis);
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
return features;
}
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
const features = [];
const byWay = new Map();
for (const road of model.roads) {
const key = road.segmentId;
if (!byWay.has(key)) byWay.set(key, []);
byWay.get(key).push(road);
}
for (const [wayKey, directions] of byWay) {
const forward = directions.find((road) => road.direction === "forward") || directions[0];
const backward = directions.find((road) => road.id !== forward.id);
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
const sides = [
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
];
for (const [side, enabled] of sides) {
if (!enabled) continue;
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
features.push(...compileSidewalkCorners(model, junctionPlans));
return features;
}
function compileSidewalkCorners(model, junctionPlans) {
const result = [];
for (const [nodeId, plan] of junctionPlans) {
const candidates = [];
for (const approach of plan.approaches) {
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
const forward = directions.find((road) => road.direction === "forward") || directions[0];
if (!forward) continue;
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
const sideStates = outwardIsForward
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const halfWidth = approach.widthMeters / 2;
for (const [side, enabled] of Object.entries(sideStates)) {
if (!enabled) continue;
// offsetLine's positive normal is driver's left, which is heading -90
// in this north-based heading convention.
const sideHeading = heading + (side === "left" ? -90 : 90);
candidates.push({
wayKey: approach.segmentId,
sourceWayKey: forward.osmWayIds.join(","),
side,
normalDegrees: sideHeading,
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
});
}
}
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
for (let index = 0; index < candidates.length; index += 1) {
const first = candidates[index];
const second = candidates[(index + 1) % candidates.length];
if (first.wayKey === second.wayKey) continue;
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
if (hasSelfIntersection(ring)) continue;
if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue;
result.push({
type: "Feature",
properties: {
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
osm_node_id: nodeId,
kind: "corner",
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
provenance: "native-road-sidewalk-corner/v1",
},
geometry: { type: "Polygon", coordinates: [ring] },
});
}
}
return result;
}
function samePhysicalSide(first, second) {
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
return Math.cos(radians) >= 0.98;
}
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
const carriageway = roadRing(approach.line, approach.widthMeters);
return carriageway && pointInPolygon(center, carriageway);
});
}
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
for (const connector of connectors) {
const junction = junctionByNode.get(connector.properties.node_id);
if (!junction) continue;
const ring = junction.geometry.coordinates[0];
if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
}
}
}
function pointInPolygon(point, ring) {
for (let index = 1; index < ring.length; index += 1) if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const a = ring[index]; const b = ring[previous];
const intersect = a[1] > point[1] !== b[1] > point[1] && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0];
if (intersect) inside = !inside;
}
return inside;
}
function pointOnSegment(point, a, b) {
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
if (Math.abs(cross) > 1e-12) return false;
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
}
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
const features = [];
const byRoadId = new Map();
for (const road of model.roads) {
const lanes = [];
const laneWidth = road.widthMeters / road.laneCount;
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
const opposite = siblings.find((item) => item.id !== road.id);
// OSM centerline is the shared carriageway center. On a two-way road,
// offset each directed carriageway to its own side before placing lanes.
const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
for (let index = 0; index < road.laneCount; index += 1) {
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
// driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
lanes.push(lane);
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, source: "native-road-lane-centerline/v1" }, geometry: { type: "LineString", coordinates } });
}
byRoadId.set(road.id, lanes);
}
return { features, byRoadId };
}
function compileConnectors(model, lanes, diagnostics, overrides) {
const features = [];
const movements = [];
for (const connection of model.connections.filter((item) => item.enabled)) {
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; }
for (let index = 0; index < fromLanes.length; index += 1) {
const turn = connectionTurn(fromRoad, toRoad);
const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex];
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
const control = connectorControlPoint(model, connection, from, to);
const coordinates = quadraticCurve(from, control, to, 12);
const length = lineLengthMeters(coordinates);
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
const provenance = override ? `override:${override.id}` : connection.provenance;
const connectorId = `connector:${id}`;
const geometryStatus = length < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector";
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
if (length < .4) { movements.push(movement); continue; }
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
movements.push(movement);
}
}
return { features, movements };
}
function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); }
function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; }
function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; }
function connectionTurn(fromRoad, toRoad) {
if (!fromRoad || !toRoad) return "unknown";
const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
const delta = ((outgoing - incoming + 540) % 360) - 180;
if (Math.abs(delta) >= 150) return "uturn";
if (Math.abs(delta) <= 30) return "through";
return delta > 0 ? "right" : "left";
}
function laneAllowsTurn(road, zeroIndex, turn) {
if (!road) return true;
const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
if (!tag) return true;
const lanes = String(tag).split("|").map((lane) => lane.split(";").map((value) => value.trim().replace("slight_", "")).filter(Boolean));
const allowed = lanes[zeroIndex];
return !allowed || allowed.includes(turn) || turn === "uturn" && allowed.includes("reverse");
}
function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
if (turn === "left") return 0;
if (turn === "right") return targetCount - 1;
if (turn === "uturn") return 0;
return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
}
function connectorControlPoint(model, connection, from, to) {
const node = endpointCoordinate(model, connection.fromEndpointId);
if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
// Nearby manual joins may not share exactly the same point. The midpoint
// keeps their curve smooth without rewriting the authoritative OSM geometry.
return node;
}
function quadraticCurve(a, control, b, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const u = 1 - t;
result.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);
}
return result;
}
function offsetLine(line, offsetMeters) {
if (line.length < 2) return null;
const origin = line[0]; const points = line.map((point) => project(point, origin)); const result = [];
for (let index = 0; index < points.length; index += 1) {
const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)];
const dx = next[0] - previous[0]; const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy);
if (length < 0.01) return null;
result.push(unproject([points[index][0] - dy / length * offsetMeters, points[index][1] + dx / length * offsetMeters], origin));
}
return result;
}
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
function polygonAreaMeters(ring) {
if (ring.length < 3) return 0;
const origin = ring[0];
const points = ring.map((point) => project(point, origin));
let twiceArea = 0;
for (let index = 0; index < points.length; index += 1) {
const next = points[(index + 1) % points.length];
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
}
return Math.abs(twiceArea) / 2;
}
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
const result = [];
for (const [nodeId, plan] of junctionPlans) {
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
if (boundary.length < 3 || !junctionMovements.length) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
continue;
}
const approachAreaMeters = polygonAreaMeters(boundary);
let ring = [...boundary, boundary[0]];
let boundaryMode = "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
ring = [...envelope, envelope[0]];
boundaryMode = "connector-convex-fallback";
}
if (hasSelfIntersection(ring)) {
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
continue;
}
const surfaceAreaMeters = polygonAreaMeters(ring);
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
return result;
}
function compileJunctionPlans(model) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
}
const plans = new Map();
for (const [nodeId, endpoints] of byNode) {
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
const approaches = junctionApproaches(model, endpoints);
if (approaches.length !== segmentIds.size) continue;
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
if (boundary.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
}
return plans;
}
function junctionApproaches(model, endpoints) {
const groups = new Map();
for (const endpoint of endpoints) {
const road = model.roads.find((item) => item.id === endpoint.roadId);
if (!road) continue;
const key = road.segmentId;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ endpoint, road });
}
return [...groups.values()].map((directions) => {
const { endpoint, road } = directions[0];
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
});
}
function junctionBoundary(approaches, node, cutbackMeters) {
const points = [];
for (const approach of approaches) {
const cutback = pointAlongLine(approach.line, cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const half = approach.widthMeters / 2;
points.push(offsetCoordinate(cutback, heading + 90, half));
points.push(offsetCoordinate(cutback, heading - 90, half));
}
return sortAround(node, points);
}
function pointAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const length = distanceMeters(line[index - 1], line[index]);
if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
remaining -= length;
}
return line.at(-1);
}
function pointAndAxisAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const start = line[index - 1]; const end = line[index];
const length = distanceMeters(start, end);
if (length < 0.01) continue;
if (length >= remaining) {
const vector = project(end, start);
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
}
remaining -= length;
}
return null;
}
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
if (!startCutback && !endCutback) return line;
const total = lineLengthMeters(line);
// Short OSM fragments cannot safely lose both ends. Keep their source
// geometry intact and let the junction diagnostic surface the ambiguity.
if (startCutback + endCutback >= total - 0.5) return line;
const result = [];
let traversed = 0;
const start = pointAlongLine(line, startCutback);
const end = pointAlongLine(line, total - endCutback);
result.push(start);
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(end);
return result;
}
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
function convexHull(points) {
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (unique.length < 3) return unique;
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const lower = []; for (const point of unique) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop(); lower.push(point); }
const upper = []; for (const point of [...unique].reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop(); upper.push(point); }
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
function interpolate(a, b, ratio) { return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio]; }
function distanceMeters(a, b) { const dx = (b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180); const dy = (b[1] - a[1]) * 111320; return Math.hypot(dx, dy); }
function hasSelfIntersection(ring) {
for (let first = 0; first < ring.length - 1; first += 1) for (let second = first + 1; second < ring.length - 1; second += 1) {
if (Math.abs(first - second) <= 1 || first === 0 && second === ring.length - 2) continue;
if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
}
return false;
}
function segmentsIntersect(a, b, c, d) {
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
const abC = cross(a, b, c); const abD = cross(a, b, d); const cdA = cross(c, d, a); const cdB = cross(c, d, b);
return (abC > 0 && abD < 0 || abC < 0 && abD > 0) && (cdA > 0 && cdB < 0 || cdA < 0 && cdB > 0);
}
function circleRing(center, radius, segments) {
const origin = center;
const ring = [];
for (let index = 0; index <= segments; index += 1) {
const angle = index / segments * Math.PI * 2;
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
}
return ring;
}
function roadRing(line, width) {
if (line.length < 2 || !Number.isFinite(width)) return null;
const origin = line[0];
const points = line.map((point) => project(point, origin));
const left = []; const right = [];
const half = width / 2;
for (let i = 0; i < points.length; i += 1) {
const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)];
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
if (length < 0.01) return null;
const nx = -dy / length * half; const ny = dx / length * half;
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin));
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin));
}
const ring = [...left, ...right.reverse(), left[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function sidewalkRing(line, innerOffset, outerOffset, side) {
const inner = offsetLine(line, innerOffset * side);
const outer = offsetLine(line, outerOffset * side);
if (!inner || !outer) return null;
const ring = [...inner, ...outer.reverse(), inner[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }
function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; }
function parseTags(body) { const tags = {}; for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; }
function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; }
function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; }
function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); }
module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };

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

@@ -0,0 +1,82 @@
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const SCHEMA = "osm-asset-package/v1";
const CATEGORIES = new Set(["scene", "roads", "buildings", "vegetation", "water"]);
const ROLES = new Set(["scene", "layer"]);
function relativeUri(uri) {
if (typeof uri !== "string" || !uri || uri.startsWith("/") || path.isAbsolute(uri) || uri.includes("\\") || uri.split("/").includes("..")) {
throw new Error(`Asset URI must be a package-relative forward-slash path: ${uri}`);
}
return uri;
}
function validateManifest(manifest, packageDir, options = {}) {
const errors = [];
const fail = (message) => errors.push(message);
if (!manifest || manifest.schema !== SCHEMA) fail(`schema must be '${SCHEMA}'`);
if (typeof manifest?.packageVersion !== "string" || !manifest.packageVersion) fail("packageVersion is required");
if (typeof manifest?.areaId !== "string" || !manifest.areaId) fail("areaId is required");
const coordinateSystem = manifest?.coordinateSystem || {};
if (coordinateSystem.axes !== "ENU" || coordinateSystem.units !== "meters" || coordinateSystem.x !== "east" || coordinateSystem.y !== "north" || coordinateSystem.z !== "up") fail("coordinateSystem must declare ENU meters (east/north/up)");
const placement = manifest?.placement || {};
for (const key of ["longitude", "latitude", "height", "headingCorrectionDegrees"]) if (!Number.isFinite(placement[key])) fail(`placement.${key} must be finite`);
if (Number.isFinite(placement.longitude) && (placement.longitude < -180 || placement.longitude > 180)) fail("placement.longitude is out of range");
if (Number.isFinite(placement.latitude) && (placement.latitude < -90 || placement.latitude > 90)) fail("placement.latitude is out of range");
const bounds = manifest?.bounds || {};
for (const key of ["minLon", "minLat", "maxLon", "maxLat"]) if (!Number.isFinite(bounds[key])) fail(`bounds.${key} must be finite`);
if (Number.isFinite(bounds.minLon) && Number.isFinite(bounds.maxLon) && bounds.minLon > bounds.maxLon) fail("bounds longitude order is invalid");
if (Number.isFinite(bounds.minLat) && Number.isFinite(bounds.maxLat) && bounds.minLat > bounds.maxLat) fail("bounds latitude order is invalid");
if (!Array.isArray(manifest?.assets) || !manifest.assets.length) fail("assets must be a non-empty array");
const ids = new Set(); let sceneCount = 0;
for (const asset of manifest?.assets || []) {
if (!asset || typeof asset.id !== "string" || !asset.id) { fail("asset id is required"); continue; }
if (ids.has(asset.id)) fail(`duplicate asset id '${asset.id}'`); ids.add(asset.id);
if (!ROLES.has(asset.role)) fail(`asset '${asset.id}' has invalid role`);
if (!CATEGORIES.has(asset.category)) fail(`asset '${asset.id}' has invalid category`);
if (typeof asset.defaultLoad !== "boolean") fail(`asset '${asset.id}' defaultLoad must be boolean`);
try { relativeUri(asset.uri); } catch (error) { fail(error.message); continue; }
if (asset.role === "scene") {
sceneCount += 1;
if (asset.category !== "scene" || !asset.defaultLoad) fail("scene asset must be category scene and defaultLoad true");
} else if (asset.category === "scene" || asset.defaultLoad) fail("layer assets must use a semantic category and defaultLoad false");
if (packageDir && options.requireFiles !== false) {
const resolved = path.resolve(packageDir, asset.uri);
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`asset '${asset.id}' is missing from package: ${asset.uri}`);
}
}
if (manifest.runtime !== undefined && !Array.isArray(manifest.runtime)) fail("runtime must be an array");
for (const runtime of manifest?.runtime || []) {
if (!runtime || typeof runtime.id !== "string" || !runtime.id) { fail("runtime asset id is required"); continue; }
if (ids.has(runtime.id)) fail(`duplicate asset id '${runtime.id}'`); ids.add(runtime.id);
if (typeof runtime.type !== "string" || !runtime.type) fail(`runtime '${runtime.id}' type is required`);
try { relativeUri(runtime.uri); } catch (error) { fail(error.message); continue; }
if (packageDir && options.requireFiles !== false) {
const resolved = path.resolve(packageDir, runtime.uri);
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`runtime '${runtime.id}' is missing from package: ${runtime.uri}`);
}
}
if (sceneCount !== 1) fail("exactly one scene asset is required");
if (errors.length) throw new Error(`Invalid asset package manifest: ${errors.join("; ")}`);
return manifest;
}
function addIntegrity(manifest, packageDir) {
for (const asset of manifest.assets) {
const file = path.resolve(packageDir, asset.uri);
const buffer = fs.readFileSync(file);
asset.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
}
for (const runtime of manifest.runtime || []) {
const file = path.resolve(packageDir, runtime.uri);
const buffer = fs.readFileSync(file);
runtime.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
}
return manifest;
}
module.exports = { SCHEMA, CATEGORIES, ROLES, relativeUri, validateManifest, addIntegrity };

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,

View File

@@ -1,43 +1,29 @@
"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;
// This layout is serialized with the anchors so Blender's static structure and
// Cesium's dynamic overlay cannot independently drift in size or handedness.
// Lateral offsets use the approach travel direction: positive is the driver's
// right. The countdown board therefore sits at +1.15m from the signal head.
const SIGNAL_LAYOUT = Object.freeze({
poleHeightMeters: 6.7,
poleRadiusMeters: 0.13,
armWidthMeters: 0.21,
// The mast arm and the signal head share this centre elevation.
mastHeightMeters: 6.25,
headCenterHeightMeters: 6.25,
headWidthMeters: 0.68,
headDepthMeters: 0.30,
headBodyHeightMeters: 1.62,
lensRadiusMeters: 0.22,
lensDepthMeters: 0.07,
lensFaceOffsetMeters: 0.18,
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,
// The countdown board is fixed on the mast arm, not hung below it.
countdownVerticalOffsetMeters: 0.0,
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
});
function buildTrafficSignals(stopLines, intersections) {
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 signals = [];
const candidates = [];
for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry);
if (!center) continue;
@@ -45,102 +31,238 @@ function buildTrafficSignals(stopLines, intersections) {
if (!intersection || metersBetween(center, intersection.point) > 32) continue;
const axis = roadAxis(feature.geometry, center, intersection.point);
if (!axis) continue;
// A vehicle signal belongs beyond the junction, facing back toward the
// approaching stop line. Use the far edge of the intersection, never the
// near-side stop-line area where it would read as a pedestrian signal.
const right = [axis[1], -axis[0]];
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
// The pole is on the far-side sidewalk, not at the stop line or inside
// the intersection. Its mast then reaches back above the approach lanes.
const point = moveMeters(farSide, right, CURB_OFFSET_METERS);
signals.push({
id: `signal-${signals.length + 1}`,
intersectionId: intersection.id,
phaseGroup: signals.length % 2,
longitude: point[0],
latitude: point[1],
stopLongitude: center[0],
stopLatitude: center[1],
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
mastReachMeters: MAST_REACH_METERS,
pose: buildSignalPose(point, axis, MAST_REACH_METERS),
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 buildSignalPose(pole, axis, mastReach) {
const lateral = [axis[1], -axis[0]];
const face = [-axis[0], -axis[1]];
const head = moveMeters(pole, lateral, -mastReach);
const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height });
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
const board = moveMeters(
moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters),
face, SIGNAL_LAYOUT.countdownFaceOffsetMeters,
);
return {
pole: position(pole, 0),
arm: {
from: position(pole, SIGNAL_LAYOUT.mastHeightMeters),
to: position(head, SIGNAL_LAYOUT.mastHeightMeters),
},
head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees },
lenses: ["red", "yellow", "green"].map((state, index) => ({
state,
...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]),
})),
countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees },
};
function signalNodeKey(signalUid) {
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
}
function readTrafficSignals(stopLinePath, intersectionPath) {
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")));
}
function polygonCenter(geometry) {
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
if (!ring || ring.length < 4) return null;
const points = ring.slice(0, -1);
return [points.reduce((sum, point) => sum + point[0], 0) / points.length, points.reduce((sum, point) => sum + point[1], 0) / points.length];
}
function polygonRadius(geometry, center) {
const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null;
if (!ring || !center) return 0;
return Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0);
}
function roadAxis(geometry, center, target) {
const ring = geometry?.coordinates?.[0];
if (!ring || ring.length < 3) return null;
let longest = null;
for (let i = 0; i < ring.length - 1; i += 1) {
const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180);
const dy = ring[i + 1][1] - ring[i][1];
const length = Math.hypot(dx, dy);
if (!longest || length > longest.length) longest = { dx, dy, length };
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}'`,
);
}
}
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;
return normalized;
}
function nearestCenter(point, centers) {
return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null;
function buildTrafficSignals(stopLines, intersections, controls = []) {
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
}
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 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 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 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);
}
module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals };
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);
}
@@ -374,6 +358,18 @@ function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
];
}
function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
const normalized = normalizeManeuver(maneuver);
if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return [];
const meters = metersForLat(center[1]);
const length = Math.hypot(axis[0], axis[1]);
if (!Number.isFinite(length) || length < 0.001) return [];
const forward = [axis[0] / length, axis[1] / length];
const right = [forward[1], -forward[0]];
return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) =>
addMeters(center, forward, forwardMeters, right, rightMeters, meters)));
}
function templateFor(assetId, manifest = loadManifest()) {
const asset = supportedAssets(manifest).get(assetId);
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
@@ -503,4 +499,4 @@ function strokePolygon(points, width) {
return [...left, ...right, left[0]];
}
module.exports = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };
module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };

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

@@ -17,8 +17,13 @@ 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,
@@ -34,6 +39,10 @@ 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)) {
@@ -43,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}`);
}
@@ -51,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(
@@ -65,14 +78,32 @@ try {
const stagedPath = path.join(stagingDir, layerFile(layer));
exportLayer(layer.id, stagedPath);
const collection = readCollection(stagedPath, layer.id);
if (layer.id === "intersection_surface") {
restoreIntersectionSurfaceIds(collection);
fs.writeFileSync(stagedPath, JSON.stringify(collection));
}
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));
@@ -117,7 +148,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;
@@ -153,6 +184,9 @@ function exportLayer(layerName, destination) {
// precision-reduction pass, which drops vertices that collapse at the given
// resolution (measured: 28 points lost across 7 lane-arrow polygons).
execFileSync(ogr2ogr, [
// GeoPackage reserves "id" as its FID column. Preserve it so the
// intersection surface can retain its osm2streets internal ID on export.
"-preserve_fid",
"-f", "GeoJSON",
destination,
gpkgPath,
@@ -163,6 +197,23 @@ function exportLayer(layerName, destination) {
});
}
function restoreIntersectionSurfaceIds(collection) {
for (const [index, feature] of collection.features.entries()) {
if (!feature || typeof feature !== "object") {
throw new Error(`intersection_surface feature ${index + 1} is invalid`);
}
const existingId = Number(feature.properties?.id);
if (Number.isInteger(existingId)) continue;
const fid = Number(feature.id);
if (!Number.isInteger(fid)) {
throw new Error(
`intersection_surface feature ${index + 1} is missing its osm2streets internal ID`,
);
}
feature.properties = { ...(feature.properties || {}), id: fid };
}
}
function readCollection(file, layerName) {
let parsed;
try {

67
scripts/road-workbench.js Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const http = require("http");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const { compileArea, parseArgs } = require("./compile-native-roads");
const repoRoot = path.resolve(__dirname, "..");
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
if (args.noCompile !== "true") compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot });
const port = Number(args.port || 8787);
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const server = http.createServer((request, response) => handle(request, response, area, configPath));
server.on("error", (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`));
}
function handle(request, response, area, configPath) {
const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
compileArea(configPath);
sendJson(response, 200, state(area));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
sendJson(response, 404, { error: "Not found" });
}
function state(area) {
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); }
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
function sendVendorFile(response, pathname) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: "Not found" });
const root = path.join(repoRoot, "node_modules", match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
}
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
if (require.main === module) main();

View File

@@ -6,8 +6,17 @@ 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");
const areaBuildSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/);
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/);
const gltf = {
nodes: [
@@ -40,7 +49,17 @@ 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.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets");
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
assert.throws(
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
/blender\.roadProvider/,
);
assert.throws(
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
/budget.reason is required/,
@@ -57,6 +76,68 @@ 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);
assert.equal(area.stages.compress, true);
assert.equal("compressedGlb" in area.outputs, false);
assert.equal("compressedMetadata" in area.outputs, false);
assert.equal("compressedCesiumPreview" in area.outputs, false);
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.mkdirSync(path.dirname(file), { recursive: true });
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

@@ -0,0 +1,12 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const { resolveStages, canonicalStages } = require("./lib/build-stages");
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]);
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]);
assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]);
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");
assert.match(buildAreaSource, /"--debug-gpu-force-workarounds"/);
console.log("Build stage tests passed.");

113
scripts/test-native-road.js Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
const { compileArea } = require("./compile-native-roads");
const { checkArea } = require("./check-native-roads");
const osm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
const empty = { schema: "native-road-overrides/v1", overrides: [] };
const initial = compileRoadModel(osm, empty);
assert.equal(initial.roads.length, 3);
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
const model = compileRoadModel(osm, overrides);
const edited = model.roads.find((road) => road.id === target.id);
assert.equal(edited.widthMeters, 9);
assert.equal(edited.provenance.widthMeters, "override:road-width");
assert.equal(edited.sidewalkLeft, false);
const geometry = compileGeometry(model);
assert.equal(geometry.roadSurface.features.length, 2);
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.sidewalkSurface.features.length, 2);
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
assert.ok(geometry.connectors.features.length > 0);
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
assert.ok(geometry.movements.length >= geometry.connectors.features.length);
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><node id="5" lon="114.001" lat="29.999"/><way id="40"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="41"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="42"><nd ref="5"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="43"><nd ref="2"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
const crossCenter = [114.001, 30];
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
assert.equal(crossGeometry.turnArrows.features.length, 0);
assert.ok(crossGeometry.directionArrows.features.length > 0);
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4);
const sharedInteriorNodeOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
assert.equal(sharedInteriorModel.roads.length, 6);
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/1:forward"));
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/2:forward"));
const sharedInteriorGeometry = compileGeometry(sharedInteriorModel);
assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
const connection = initial.connections[0];
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
assert.ok(compileGeometry(compileRoadModel(osm, connectionOverrides)).connectors.features.length < geometry.connectors.features.length);
const disconnectedOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.00105" lat="30"/><node id="4" lon="114.002" lat="30"/><way id="20"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way><way id="21"><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
const disconnected = compileRoadModel(disconnectedOsm, empty);
const from = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/20:forward" && endpoint.side === "end");
const to = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/21:forward" && endpoint.side === "start");
const manualOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "manual", kind: "junction-connection", fromEndpointId: from.id, toEndpointId: to.id, enabled: true }] }, disconnected);
assert.ok(compileRoadModel(disconnectedOsm, manualOverrides).connections.some((item) => item.fromEndpointId === from.id && item.toEndpointId === to.id));
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "same-way", kind: "junction-connection", fromEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:forward" && endpoint.side === "end").id, toEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:backward" && endpoint.side === "start").id, enabled: true }] }, initial), /manual junction connection/);
const turnOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="30"><nd ref="1"/><nd ref="2"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/><tag k="turn:lanes" v="left|through|right"/></way><way id="31"><nd ref="2"/><nd ref="3"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/></way></osm>`;
const turnModel = compileRoadModel(turnOsm, empty);
const turnGeometry = compileGeometry(turnModel);
assert.equal(turnGeometry.sidewalkSurface.features.length, 0);
assert.ok(turnGeometry.directionArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.every((feature) => feature.properties.provenance === "native-road-turn-arrow/v1"));
const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel);
assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left"));
assert.equal(turnGeometry.connectors.features.length, 1);
assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:way\/30:forward:1$/);
assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/);
const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel);
assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0);
assert.equal(compileGeometry(turnModel, laneOverrides).movements.length, 0);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-"));
try {
const input = path.join(freshArea, "input.osm");
const outputRoot = path.join(freshArea, "outputs");
const config = path.join(freshArea, "area.json");
fs.writeFileSync(input, osm);
fs.writeFileSync(config, JSON.stringify({ id: "fresh", input, outputRoot }));
const compiledArea = compileArea(config);
assert.equal(compiledArea.result.areaId, "fresh");
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
assert.equal(checkArea(config).ok, true);
} finally {
fs.rmSync(freshArea, { recursive: true, force: true });
}
console.log("native road tests passed");

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { SCHEMA, validateManifest } = require("./lib/package-contract");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-package-"));
fs.mkdirSync(path.join(dir, "models"));
fs.writeFileSync(path.join(dir, "models", "area.glb"), "glb");
fs.writeFileSync(path.join(dir, "runtime.json"), "{}");
const valid = { schema: SCHEMA, packageVersion: "1.0.0", areaId: "area", coordinateSystem: { axes: "ENU", units: "meters", x: "east", y: "north", z: "up" }, placement: { longitude: 120, latitude: 30, height: 0, headingCorrectionDegrees: -90 }, bounds: { minLon: 119, minLat: 29, maxLon: 121, maxLat: 31 }, assets: [{ id: "main", role: "scene", category: "scene", uri: "models/area.glb", defaultLoad: true }], runtime: [{ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime.json" }] };
assert.doesNotThrow(() => validateManifest(valid, dir));
for (const change of [
(m) => { m.schema = "bad"; }, (m) => { m.assets[0].uri = "../area.glb"; },
(m) => { m.assets[0].category = "dynamic"; }, (m) => { m.assets.push({ ...m.assets[0] }); },
(m) => { m.placement.longitude = 900; },
]) {
const manifest = JSON.parse(JSON.stringify(valid)); change(manifest);
assert.throws(() => validateManifest(manifest, dir));
}
fs.rmSync(dir, { recursive: true, force: true });
console.log("Asset package contract tests passed.");

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { resolveAsset, resolveRuntime, placement } = require("../examples/asset-package-resolver");
const manifest = { coordinateSystem: { axes: "ENU" }, placement: { longitude: 120, latitude: 30, height: 2, headingCorrectionDegrees: -90 }, assets: [{ id: "main", uri: "models/a.glb" }, { id: "roads", uri: "models/roads.glb" }], runtime: [{ id: "traffic-signals", uri: "runtime/traffic-signals.json" }] };
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest), "https://consumer.example/copied/models/a.glb");
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest, "roads"), "https://consumer.example/copied/models/roads.glb");
assert.equal(resolveRuntime("https://consumer.example/copied/manifest.json", manifest, "traffic-signals"), "https://consumer.example/copied/runtime/traffic-signals.json");
assert.equal(placement(manifest).coordinateSystem.axes, "ENU");
console.log("Asset package example resolver tests passed.");

Some files were not shown because too many files have changed in this diff Show More