Compare commits
48 Commits
950cd1c4cd
...
feature/na
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8a3622b9 | |||
| 03a8d6159b | |||
| 1b9829d9ed | |||
| 822e6ef936 | |||
| 3eea12c6ea | |||
| 3d57655497 | |||
| 65cf8b96d9 | |||
| e1f3fc10ca | |||
| f43a122ff7 | |||
| 3e22a1872e | |||
| 1da985c932 | |||
| 817e361477 | |||
| 25287777f6 | |||
| a556c0fc97 | |||
| 1876472bf8 | |||
| 707e7f82f9 | |||
| 850e9eb344 | |||
| df81a22ce8 | |||
| b5fa4482f0 | |||
| ddd15f68b3 | |||
| aa5b1f85c8 | |||
| 761a52646c | |||
| f13d890ffb | |||
| 41a0e81a6f | |||
| eff7a01b2b | |||
| 0102ffbb3c | |||
| f2b8d79f5d | |||
| db0fba5bb5 | |||
| c925890848 | |||
| f385009043 | |||
| b4a81331e8 | |||
| e0cdf9e00b | |||
| 0790cbd0a9 | |||
| b68be063ad | |||
| d26921c6d1 | |||
| 96a99a671b | |||
| e89b649256 | |||
| 5658e7337d | |||
| aeb2cec021 | |||
| a749abf00c | |||
| 8237c7ed27 | |||
| 808266127d | |||
| 22f306249d | |||
| d382bc4462 | |||
| 208ed055ac | |||
| e153a1c57d | |||
| 72fa04ddeb | |||
| 1c077a312e |
@@ -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 材质索引整体平移 |
|
||||
|
||||
@@ -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 |
|
||||
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 中有三个以上相连 way;osm2stree
|
||||
|
||||
**教训**:**跨阶段契约必须随产物保存;兼容旧产物的字符串回退也要被审查**。
|
||||
|
||||
### 坑 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 从当前权威产物重建;同时要
|
||||
区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。**
|
||||
|
||||
### 坑 7:GeoJSON 内部正确,但 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-坐标契约)
|
||||
|
||||
---
|
||||
|
||||
## 加东西时的检查清单
|
||||
|
||||
@@ -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` 原样服务这些
|
||||
GeoJSON,native 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`、GeoPackage 或 QGIS 工程;这些层只能
|
||||
继续包含九个道路场景图层。
|
||||
- `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 和 violations;warnings 来自同一个预算评估
|
||||
|
||||
`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,且独立入口的独立性是刻意的 |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 两种调用姿势
|
||||
|
||||
| 阶段 | 参数 | 出处 |
|
||||
|
||||
@@ -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 | 产物一致性校验驱动 |
|
||||
|
||||
@@ -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()` 里加进注入的 JSON,JS 侧从 `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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal file
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal 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."}
|
||||
110
.trellis/tasks/08-13-native-road-compiler/design.md
Normal file
110
.trellis/tasks/08-13-native-road-compiler/design.md
Normal 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.
|
||||
@@ -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."}
|
||||
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal file
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal 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.
|
||||
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal file
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal 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.
|
||||
@@ -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.
|
||||
28
.trellis/tasks/08-13-native-road-compiler/task.json
Normal file
28
.trellis/tasks/08-13-native-road-compiler/task.json
Normal 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": {}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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 无法解析或不是 FeatureCollection:preview 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 Analysis:GLB 与巡航路线随距离漂移
|
||||
|
||||
### 1. Root Cause Category
|
||||
|
||||
- **Category**:B - Cross-Layer Contract;D - 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。用户已有区域配置修改保持不动。
|
||||
@@ -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."}
|
||||
@@ -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.415–1.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 和区域质量门。
|
||||
@@ -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 动画消费契约。
|
||||
- R7:preview 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] AC7:preview 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 输入错误,单个路段无法可靠匹配则跳过并写诊断。
|
||||
@@ -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 ring;ring 前半边与反向后的后半边一一对应,其中点可恢复原始 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。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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:资产包只含可复用的静态交付资产;主场景和道路、建筑、植被、水体等分层模型必须有
|
||||
明确的角色、加载语义和默认行为。
|
||||
- R3:manifest 和包内文件不得包含桌面绝对路径、构建临时目录、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>/` 中由用户保留的历史调试产物。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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、取消输入或无效输入时必须安全退出,不启动任何构建。
|
||||
- R7:TTY 交互使用方向键移动、空格多选、回车确认;菜单应显示当前选择状态。
|
||||
- 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`.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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 内容。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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**
|
||||
|
||||
41
.trellis/workspace/que01/index.md
Normal file
41
.trellis/workspace/que01/index.md
Normal 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
|
||||
28
.trellis/workspace/que01/journal-1.md
Normal file
28
.trellis/workspace/que01/journal-1.md
Normal 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**
|
||||
71
README.md
71
README.md
@@ -4,15 +4,19 @@
|
||||
|
||||
## 目标产物
|
||||
|
||||
每个区域默认输出到 `outputs/<area-id>/`:
|
||||
每个区域默认输出到 `outputs/<area-id>/`。其中只有 `package/` 是可复制给下游的发布边界:
|
||||
|
||||
- `package/manifest.json`:版本化 `osm-asset-package/v1` manifest,声明 WGS84 anchor、ENU(X 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 counts,Blender 记录 `.blend` / render,Cesium/压缩记录 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`)做两件事:
|
||||
|
||||
BIN
assets/preview/vehicle-accident.png
Normal file
BIN
assets/preview/vehicle-accident.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/preview/vehicle-breakdown.png
Normal file
BIN
assets/preview/vehicle-breakdown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
4
assets/qgis/traffic-signal-direction.svg
Normal file
4
assets/qgis/traffic-signal-direction.svg
Normal 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 |
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "osm2streets"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
|
||||
19
examples/asset-package-resolver.js
Normal file
19
examples/asset-package-resolver.js
Normal 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 };
|
||||
20
examples/cesium-asset-package.js
Normal file
20
examples/cesium-asset-package.js
Normal 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 };
|
||||
17
examples/three-asset-package.js
Normal file
17
examples/three-asset-package.js
Normal 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
617
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
43
scripts/check-native-roads.js
Normal file
43
scripts/check-native-roads.js
Normal 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 };
|
||||
112
scripts/compile-native-roads.js
Normal file
112
scripts/compile-native-roads.js
Normal 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 };
|
||||
@@ -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") {
|
||||
|
||||
49
scripts/interactive-build.js
Normal file
49
scripts/interactive-build.js
Normal 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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
43
scripts/lib/build-stages.js
Normal file
43
scripts/lib/build-stages.js
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
161
scripts/lib/lane-geometry.js
Normal file
161
scripts/lib/lane-geometry.js
Normal 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
743
scripts/lib/native-road.js
Normal 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
102
scripts/lib/osm.js
Normal 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 };
|
||||
82
scripts/lib/package-contract.js
Normal file
82
scripts/lib/package-contract.js
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
67
scripts/road-workbench.js
Normal 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();
|
||||
@@ -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.");
|
||||
|
||||
12
scripts/test-build-stages.js
Normal file
12
scripts/test-build-stages.js
Normal 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
113
scripts/test-native-road.js
Normal 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");
|
||||
25
scripts/test-package-contract.js
Normal file
25
scripts/test-package-contract.js
Normal 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.");
|
||||
10
scripts/test-package-examples.js
Normal file
10
scripts/test-package-examples.js
Normal 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
Reference in New Issue
Block a user