35 Commits

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

View File

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

View File

@@ -21,13 +21,14 @@
全部是 CommonJS`package.json``"type": "commonjs"`),无构建步骤、无 TypeScript、
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
## 可选 GLB 压缩阶段
## 默认 GLB 压缩阶段
### 1. Scope / Trigger
`compress`显式请求的替代产物阶段,不属`all`,也不覆盖默认 `<area-id>.glb`
它用于在已有 Cesium GLB 上生成并列压缩 GLB、metadata 和预览页。当前主路径是
texture resize + WebP transcode。
`compress`完整构建的默认阶段,`cesium``package` 之间。它在
`_pipeline/package-staging/` 的主 GLB 上执行 texture resize + WebP transcode并替换 staged
主 GLB 与 staged manifest不触碰已发布的 `package/`,也不重写 preview HTML。未压缩版本只在
`<areaDir>/_pipeline/compress-*` 临时目录中存在,成功或失败后都会清理。
`scripts/compress-glb.js` 是该阶段调用的低层脚本,也可单独运行做实验。
@@ -62,20 +63,17 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
### 3. Contracts
- 输入必须是现有 `.glb` 文件;`--output` 必须不同于 `--input`
- 输出是并列压缩 GLB默认构建产物不被替换
- `compress` 阶段依赖默认 `glb``metadata` `cesiumPreview` 已存在。
- 低层脚本仍输出到与输入不同的路径;`build-area.js` 使用临时输入/输出路径,避免原地压缩
- `compress` 阶段依赖 staged `glb``metadata` 已存在,并在全部临时交付文件生成和校验后
`renameSync` 替换它们。
- 默认链固定为 `gltf-transform resize -> gltf-transform webp`
- `compress` 阶段默认 `textureSize=768`;低层脚本单独运行时默认 `--texture-size 1024`
- `build-area.js` 默认写:
- `<fileStem>-compressed-webp768.glb`
- `<fileStem>-compressed-webp768.json`
- `<fileStem>-compressed-webp768-cesium-preview.html`
- 伴生 metadata 的 `asset``id="main"` 资产 URL 改为压缩 GLB 文件名;其余资产(包括
`category="semantic"` 的 Cesium 分类检查 GLB必须原样保留。
- preview HTML 只替换 `window.OSM_ASSET_PREVIEW_CONFIG``glbName` /
`metadataName` 和 loading 文案,不改 preview runtime。
- `build-area.js` 不会写并列 `-compressed-*` 交付物;`package` 是唯一能发布静态文件到
`package/` 的阶段。
- staged manifest 的 `assets[*].uri` 必须保持 package-relative`models/<area>.glb`;分类
layer GLB 必须原样保留。
- 成功时 stdout 打印 `GLB_COMPRESS_DONE <json>`包含压缩前后大小、image /
non-image bytes、结构计数、扩展metadata / preview 输出路径。
non-image bytes、结构计数、扩展metadata 输出路径。
### 4. Validation & Error Matrix
@@ -86,27 +84,24 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
| `--output` 等于 `--input` | 抛错,避免覆盖源 GLB |
| 数值参数超范围 | 抛错并指出合法范围 |
| `gltf-transform` 退出非零 | 抛错并带上 status / signal |
| `--preview` 没有 `--metadata` | 抛错,因为 preview 必须指向存在的 metadata |
| preview HTML 找不到配置块 | 抛错,不做猜测替换 |
| `--stages compress` 但默认 preview 不存在 | `Cesium preview not found: <path>` |
| `--stages compress` 但 staged manifest 不存在 | `Cesium metadata not found: <path>` |
### 5. Good/Base/Bad Cases
- Good: `--stages cesium,compress` 先重导默认 GLB再生成并列压缩产物
- Good: `--texture-size 768 --metadata --preview` 生成压缩 GLB、metadata、HTML
源 GLB 保持不变。
- Good: 默认构建先导出 Cesium再压缩 staged 资产,最后由 `package` 发布
- Good: `--stages compress` 对已有 staged 交付物重新压缩,临时源 GLB 不会留在输出目录。
- Base: 只传 `--input --output` 生成压缩 GLB不生成伴生文件。
- Bad: 使用 `--meshopt` 后没有做 Cesium 兼容性验证就当默认产物发布。
### 6. Tests Required
- `node --check scripts/compress-glb.js`
- `npm run test:compress-glb`:断言压缩 metadata 只替换主资产,不丢失语义资产
- `npm run test:compress-glb`:断言压缩 manifest 只替换主资产,不丢失语义 layer
- `node --check scripts/build-area.js`
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata --preview`
- 对目标区域跑一次 `npm run build:area -- --stages compress`
- `node scripts/glb-digest.js <compressed.glb>` 确认可解析结构和扩展
- 浏览器/Cesium 预览压缩 HTML确认 `EXT_texture_webp` 在目标环境可加载
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata`
- 对目标区域跑一次默认 `npm run build:area`
- `node scripts/glb-digest.js <area>.glb` 确认可解析结构和扩展
- 浏览器/Cesium 预览标准 HTML确认 `EXT_texture_webp` 在目标环境可加载
### 7. Wrong vs Correct
@@ -116,10 +111,110 @@ Wrong:
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a.glb
```
Correct:
Correct for the low-level script:
```bash
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed-webp768.glb
npm run compress:glb -- --input outputs/a/a.glb --output /tmp/a-compressed.glb
```
---
## 可复用资产包发布
### 1. Scope / Trigger
`package` 将已验证的 static staging 提升为下游可消费的唯一交付边界:
`<areaDir>/package/`。它不复制大文件到第二个发布目录;成功时将整个 staging 目录原子 rename
为 package。车辆、路线和动态信号属于预览能力必须留在 `<areaDir>/_preview/`,不能进入 package。
### 2. Signatures
```bash
npm run build:area -- --config config/areas/<area>.json --stages cesium,compress,package
npm run build:area -- --config config/areas/<area>.json --stages preview
node scripts/test-package-contract.js
node scripts/test-package-examples.js
```
下游入口永远是:
```text
outputs/<area>/package/manifest.json
```
### 3. Contracts
发布 manifest 使用 `schema: "osm-asset-package/v1"`,至少包含:
```json
{
"schema": "osm-asset-package/v1",
"packageVersion": "1.0.0",
"areaId": "example",
"coordinateSystem": { "axes": "ENU", "units": "meters", "x": "east", "y": "north", "z": "up" },
"placement": { "longitude": 114.3, "latitude": 30.5, "height": 0, "headingCorrectionDegrees": -90 },
"bounds": { "minLon": 114.2, "minLat": 30.4, "maxLon": 114.4, "maxLat": 30.6 },
"assets": [{
"id": "main",
"role": "scene",
"category": "scene",
"uri": "models/example.glb",
"defaultLoad": true,
"integrity": { "bytes": 123, "sha256": "..." }
}]
}
```
- `coordinateSystem` 固定为 ENU meters`placement` 是 WGS84 锚点与 heading correction。
- 必须恰有一个 `role="scene"` / `category="scene"` / `defaultLoad=true` 主资产roads、buildings、
vegetation、water 是 `role="layer"``defaultLoad=false` 的可选语义层。
- `assets[*].uri``runtime[*].uri` 只能是包内正向相对路径,禁止绝对路径、反斜杠和 `..`
- `runtime` 声明可编程运行时资产;当前至少包含 `traffic-signals` 锚点 JSON、
`traffic-signals-dynamic` 灯珠 GLB以及两个 `traffic-signals-countdown-*` 倒计时 GLB。
它们是资产包的一部分,不属于 `_preview/`
- `package` 在晋升前调用 `validateManifest()`;再为每个模型写 bytes 与 SHA-256然后复验。
- `package` 是唯一拥有最终 package file records 的 stage。`cesium` / `compress` 只能拥有 staging
或计算摘要,不能留下指向最终 package 文件的 outputs record。
- 下游代码和 preview 都从 manifest URL 解析 `assets[*].uri`。参考
`examples/cesium-asset-package.js``examples/three-asset-package.js`
### 4. Validation & Error Matrix
| 条件 | 结果 |
|---|---|
| staging manifest 缺失或无效 JSON | `package` 失败,不替换上次成功 package |
| URI 是绝对路径、含 `..``\\` | `validateManifest()` 失败 |
| ENU / WGS84 字段缺失或越界 | `validateManifest()` 失败 |
| 没有或多于一个 scene asset | `validateManifest()` 失败 |
| manifest 声明的模型文件缺失 | `validateManifest()` 失败 |
| rename 发布异常 | 尝试恢复 `<package>.previous`,保留原 package |
### 5. Good/Base/Bad Cases
- Good: 下游只带走 `package/`,按 manifest 的 WGS84 placement 放置主模型,按需加载 layer。
- Base: 只有 main scene 的区域仍是合法 package没有几何的语义类别不写空 GLB。
- Bad: 将 `_preview/` 的车辆模型或路线加进 package它们是本项目的验证运行时信号灯 runtime 则必须发布。
### 6. Tests Required
- `npm run test:package-contract`schema、URI、placement、唯一 scene 和模型存在性。
- `npm run test:package-examples`Cesium / Three.js resolver 都相对 manifest 解析 URI。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress,package,preview`
- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
-`package/` 复制到其他目录后,重新运行 manifest validator所有 `assets[*].uri` 必须仍能解析。
### 7. Wrong vs Correct
Wrong:
```js
const modelUrl = asset.uri; // Relative to preview HTML by accident.
```
Correct:
```js
const modelUrl = new URL(asset.uri, new URL(manifestUrl, window.location.href)).href;
```
---
@@ -272,6 +367,53 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
```
## Native 道路标线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
### 2. 图层契约
| 文件 | 语义 | 必需 provenance | Blender material layer |
|---|---|---|---|
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
方向箭头必须带 `road_id``lane_id``osm_way_ids``direction``lane_index`
`sequence``distance_along_lane_meters``placement_interval_meters`。转向箭头
必须带 `maneuver``placement_distance_meters`。两者不能共用 provenance 或假装为
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
### 3. 放置与错误矩阵
| 条件 | 结果 |
|---|---|
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
### 4. 必需测试
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距以及无标签道路
不生成路口转向箭头。
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
`direction_arrows``turn_arrows`,且不运行 `package`
### 5. 错误与正确写法
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。
## 斑马线与停止线来源
### 1. 范围与触发条件
@@ -607,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` /
增量构建判断。
@@ -629,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
```
代码入口:
@@ -721,9 +864,18 @@ File records use this shape:
- `summary.glb.extensionsUsed`
- `summary.budget`effective limits、usage 和 violationswarnings 来自同一个预算评估
`cesium` 会调用 preview 生成函数,但 preview HTML / route / vehicle model 的 freshness
所有权属于独立 `preview` manifest。否则单跑 `--stages preview` 会把 Cesium manifest
错误判 stale。
`cesium` 只拥有 staging 内的静态模型与 manifest完成后必须依次由 `compress``package`
交付。preview HTML / route / vehicle model 的 freshness 所有权属于独立 `preview` manifest
`compress` manifest 不记录最终文件 records它们位于可被 `package` rename 的 staging 目录。
`package` manifest 是唯一记录 `package/manifest.json`、主 GLB 与 package 目录的 stage manifest
避免发布后 compress manifest 因路径迁移而立刻 stale。
`package` manifest:
- `inputs.stagingManifest`:发布后的同一 manifest 文件记录
- `outputs.packageDir``outputs.manifest``outputs.primaryGlb`
- `summary.assets``summary.packageDir`
### Preview Assembly Boundary
@@ -758,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`
@@ -791,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
提示重跑对应阶段。
@@ -811,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`
@@ -929,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/` 动态资源的情况
### 别名
@@ -952,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` 互斥
@@ -966,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`
---
@@ -1005,7 +1155,7 @@ parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SC
| 在阶段函数里现拼输出路径 | 路径规则出现第二份定义 |
| 低层脚本直接读 `config/areas/*.json` | 打破两层配置边界 |
| 布尔配置用 `\|\|` 兜底 | `false` 被翻转成默认值 |
| 让 `reimport` / `preview` 能从配置文件默认开启 | 恢复动作变成常规行为 |
| 让 `reimport` 能从配置文件默认开启 | 恢复动作变成常规行为 |
| 新阶段忘了 `ensureFile` 前置校验 | 单跑时报底层堆栈而非人话 |
| 改 stage 的 stdout 标记 | 静默破坏 parity 契约 |
| 顺手把多份 `parseArgs` 合并 | 扩大 diff且独立入口的独立性是刻意的 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
# Interactive area build CLI
## Goal
提供一个交互式区域构建入口,减少手写 `npm run build:area -- --config ... --stages ...`
的频率用户可选择区域和一个或多个构建阶段CLI 按既有阶段约束执行。
## Confirmed Facts
- 当前 `scripts/build-area.js` 支持 `intermediates``reimport``blender``cesium`
`preview``compress` 六个阶段,并接受逗号分隔的 `--stages`
- `intermediates``reimport` 互斥:前者会从 OSM 重建 GeoPackage后者回导 QGIS 编辑。
- 执行顺序固定为 `intermediates/reimport -> blender -> cesium -> preview -> compress`
`cesium` 当前会自动写入 preview 产物。
- 项目没有终端交互依赖;现有命令行调用必须继续可用于脚本和自动化。
## Requirements
- R1提供交互式入口列出可用区域配置并允许用户选择目标区域。
- R2交互式入口允许一次选择多个阶段并清晰展示阶段名称与用途。
- R3选择互斥阶段时必须阻止执行并解释原因不得静默选择其中之一。
- R4执行多个阶段时必须使用既有依赖顺序而非用户勾选顺序。
- R5保留 `npm run build:area -- --config ... --stages ...` 的现有非交互行为与语义。
- R6无 TTY、取消输入或无效输入时必须安全退出不启动任何构建。
- R7TTY 交互使用方向键移动、空格多选、回车确认;菜单应显示当前选择状态。
- R8`npm run build` 启动交互式入口;`npm run build:area` 继续保留为非交互入口。
## Acceptance Criteria
- [ ] 用户可从终端选择区域和多项阶段,无需手写配置路径或逗号分隔阶段名。
- [ ] 选择 `intermediates``reimport`CLI 明确报互斥错误且不运行构建。
- [ ] 选择乱序的多个阶段时,实际执行顺序仍遵循既有管线顺序。
- [ ] 既有非交互 `build:area` 调用保持兼容。
- [ ] 取消或在非交互环境调用不会触发构建。
- [ ] 用户可用方向键、空格和回车完成区域及阶段选择。
- [ ] `npm run build` 启动交互菜单,`npm run build:area` 的现有调用保持不变。
## Key Decisions
- 采用终端原生多选菜单,而非输入逗号分隔编号;避免引入运行时依赖。
- `npm run build` 作为日常交互入口;`npm run build:area` 继续服务自动化和精确调用。
## Out Of Scope
- 修改各 stage 的构建业务逻辑、依赖关系或产物格式。
- 将巡航路线、QGIS 图层编辑或区域配置编辑纳入本次交互流程。
- GUI 或浏览器界面的构建控制台。
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

@@ -0,0 +1,26 @@
{
"id": "interactive-area-cli",
"name": "interactive-area-cli",
"title": "Interactive area build CLI",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-11",
"completedAt": "2026-08-11",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

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

View File

@@ -0,0 +1,48 @@
# Design: Vehicle Incident And Information Preview
## Boundary
This is browser-only Cesium preview state. It changes neither generated OSM data nor `package/` assets.
Vehicle routes and models remain `_preview/` resources; refresh resets all vehicle event state.
## Data Model
Each object returned by `addCruiseVehicle()` gains:
```js
{
id: "vehicle-1",
modelName: "car_a01_002",
status: "normal" | "breakdown" | "accident",
incidentNote: "",
motion: { state },
markerEntity
}
```
The current route distance remains the single motion source. The clock tick leaves it unchanged while
status is not `normal`; returning to normal resumes from that position.
## Interaction Flow
1. Vehicle Cesium Entities carry a `vehicleId` property.
2. A `ScreenSpaceEventHandler` picks a clicked entity, resolves its vehicle, synchronizes the existing
vehicle selector, and opens a positioned information card.
3. The card displays vehicle number, model family, route label/length, configured speed, state, and note.
4. Card controls set normal/breakdown/accident and persist only in the in-memory vehicle object.
5. State changes update marker visibility, the route material, card content, and Cesium render request.
## Visual Contract
- Normal: no marker; original route color; motion active.
- Breakdown: yellow wrench label above the vehicle; route retains its original color; motion frozen.
- Accident: red warning-triangle label above the vehicle; route material becomes red; motion frozen.
- Marker positions use the same vehicle `CallbackProperty`, so they move with the vehicle before an event
and remain at the incident location while stopped.
## Compatibility And Risks
- Picking must ignore routes, static models, and signal entities.
- No route must retain the existing disabled cruise controls and never show a card.
- The card must be HTML/CSS UI, not Cesium InfoBox, because the viewer deliberately disables InfoBox.
- The feature must not alter signal runtime behavior or vehicle asset URLs.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "vehicle-incident-info",
"name": "vehicle-incident-info",
"title": "Vehicle incident and information preview",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-12",
"completedAt": "2026-08-12",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 27
- **Last Active**: 2026-08-07
- **Total Sessions**: 31
- **Last Active**: 2026-08-14
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~575 | Active |
| `journal-1.md` | ~663 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,10 @@
<!-- @@@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` |

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

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

617
package-lock.json generated
View File

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

View File

@@ -4,22 +4,32 @@
"private": true,
"type": "commonjs",
"scripts": {
"build": "node scripts/build-area.js",
"build": "node scripts/interactive-build.js",
"build:area": "node scripts/build-area.js",
"build:qgis": "node scripts/build-osm2streets-qgis.js",
"check:area": "node scripts/check-area.js",
"compress:glb": "node scripts/compress-glb.js",
"diagnose:area": "node scripts/diagnose-area.js",
"preflight:area": "node scripts/preflight-area.js",
"road:compile": "node scripts/compile-native-roads.js",
"road:check": "node scripts/check-native-roads.js",
"road:workbench": "node scripts/road-workbench.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js",
"test:budgets": "node scripts/test-asset-budgets.js",
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:compress-glb": "node scripts/test-compress-glb.js",
"test:package-contract": "node scripts/test-package-contract.js",
"test:package-examples": "node scripts/test-package-examples.js",
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
"test:traffic-signals": "node scripts/test-traffic-signals.js",
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}
}

View File

@@ -4,6 +4,9 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
const { resolveStages } = require("./lib/build-stages");
const { validateManifest, addIntegrity } = require("./lib/package-contract");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
const {
SCENE_LAYERS,
@@ -40,17 +43,12 @@ const requestedStages = args.stages
? splitList(args.stages)
: null;
const stages = resolveStages(area.stages, requestedStages);
// intermediates deletes and rebuilds the GeoPackage from OSM, which is exactly
// the manual work reimport exists to recover. Refuse the combination instead of
// silently letting one undo the other.
if (stages.intermediates && stages.reimport) {
throw new Error(
"Stages 'intermediates' and 'reimport' are mutually exclusive: " +
"intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.",
);
const roadProvider = args.roadProvider || area.blender.roadProvider;
if (!new Set(["osm2streets", "native"]).has(roadProvider)) {
throw new Error("--road-provider must be \"osm2streets\" or \"native\".");
}
console.log(`Area: ${area.id}`);
console.log(`Config: ${configPath}`);
console.log(`Output: ${area.outputs.areaDir}`);
@@ -62,17 +60,20 @@ if (stages.reimport) {
reimportGpkg(area);
}
if (stages.blender) {
buildBlenderScene(area);
buildBlenderScene(area, roadProvider);
}
if (stages.cesium) {
exportCesium(area);
}
if (stages.preview) {
writeCesiumPreview(area);
exportCesium(area, roadProvider);
}
if (stages.compress) {
compressCesiumGlb(area);
}
if (stages.package) {
publishPackage(area);
}
if (stages.preview) {
writeCesiumPreview(area);
}
console.log("Done.");
@@ -100,49 +101,6 @@ function splitList(value) {
.filter(Boolean);
}
function resolveStages(defaults, requested) {
if (!requested) return defaults;
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build.
// 'compress' is also absent from 'all': it creates an alternate Cesium GLB,
// not the baseline asset.
const aliases = {
all: ["intermediates", "blender", "cesium"],
qgis: ["intermediates"],
osm2streets: ["intermediates"],
geojson: ["intermediates"],
intermediate: ["intermediates"],
intermediates: ["intermediates"],
reimport: ["reimport"],
gpkg: ["reimport"],
blender: ["blender"],
scene: ["blender"],
cesium: ["cesium"],
glb: ["cesium"],
preview: ["preview"],
html: ["preview"],
cesiumPreview: ["preview"],
compress: ["compress"],
compression: ["compress"],
compressedCesium: ["compress"],
};
const out = {
intermediates: false,
reimport: false,
blender: false,
cesium: false,
preview: false,
compress: false,
};
for (const stage of requested) {
const mapped = aliases[stage];
if (!mapped) {
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
}
for (const key of mapped) out[key] = true;
}
return out;
}
function writeDerivedConfig(area) {
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
@@ -255,16 +213,22 @@ function reimportGpkg(area) {
});
}
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.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");
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",
@@ -274,8 +238,6 @@ function buildBlenderScene(area) {
"--",
"--osm",
area.input,
"--geojson",
area.outputs.geojsonDir,
"--output",
area.outputs.blend,
"--render",
@@ -283,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);
}
@@ -302,17 +269,20 @@ function buildBlenderScene(area) {
inputs: {
config: fileRecord(configPath),
osm: fileRecord(area.input),
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
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,
},
@@ -320,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"),
"--",
@@ -340,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, {
@@ -369,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),
});
@@ -385,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);
}
@@ -396,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: [],
});
}
@@ -490,9 +543,8 @@ 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");
@@ -508,11 +560,14 @@ function writeCesiumPreview(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, {
@@ -525,8 +580,8 @@ 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),
@@ -537,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: [],

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,39 +25,48 @@ 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`),
),
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`)),
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"),
),
@@ -79,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,
@@ -107,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,
@@ -162,6 +173,14 @@ function numberOption(value, fallback, label, min, max) {
return number;
}
function roadProviderOption(value) {
const provider = value ?? "osm2streets";
if (provider !== "osm2streets" && provider !== "native") {
throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\".");
}
return provider;
}
function integerOption(value, fallback, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isInteger(number) || number < 1) {

View File

@@ -10,6 +10,7 @@ const {
layerFile,
} = require("./scene-layers");
const { digest: glbDigest } = require("../glb-digest");
const { validateManifest } = require("./package-contract");
const {
BUDGETS,
evaluateGlbBudget,
@@ -31,8 +32,8 @@ function analyzeArea(configPath, options = {}) {
const artifacts = artifactStatus(area);
const manifests = stageManifestStatus(area, resolvedConfig);
const metadataWarnings = [];
const metadata = metadataSummary(area.outputs.metadata, metadataWarnings);
const glb = fs.existsSync(area.outputs.glb) ? glbDigest(area.outputs.glb) : null;
const metadata = metadataSummary(area.outputs.packageManifest, metadataWarnings);
const glb = fs.existsSync(area.outputs.packagePrimaryGlb) ? glbDigest(area.outputs.packagePrimaryGlb) : null;
const warnings = [
...metadataWarnings,
...collectWarnings(area, osm, artifacts, manifests, glb, metadata),
@@ -328,15 +329,12 @@ function artifactStatus(area) {
["QGIS project", area.outputs.qgisProject, true, "file"],
["QGIS preview", area.outputs.qgisPreview, true, "file"],
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
["Traffic signal runtime", area.outputs.trafficSignals, true, "file"],
["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);
@@ -362,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,
@@ -374,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");
@@ -403,7 +403,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
gpkg: area.outputs.gpkg,
qgisProject: area.outputs.qgisProject,
qgisPreview: optionalExpectedFile(area.outputs.qgisPreview),
@@ -421,7 +421,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
},
},
{
@@ -433,7 +433,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
},
outputs: {
blend: area.outputs.blend,
@@ -446,7 +446,7 @@ function stageManifestStatus(area, configPath = null) {
inputs: {
blend: area.outputs.blend,
},
outputs: {
outputs: compressionComplete ? {} : {
glb: area.outputs.glb,
metadata: area.outputs.metadata,
},
@@ -457,15 +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,
@@ -473,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,
},
},
];
@@ -749,7 +756,7 @@ function classifyAreaQuality(result) {
warnings.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`);
}
const fatalArtifacts = new Set(["Cesium GLB", "Cesium metadata", "Cesium preview"]);
const fatalArtifacts = new Set(["Package manifest", "Package primary GLB", "Cesium preview"]);
for (const artifact of artifacts) {
if (fatalArtifacts.has(artifact.label)) {
if (!artifact.exists) {

View File

@@ -4,8 +4,13 @@ const fs = require("fs");
const path = require("path");
function writeCesiumPreviewSupportFiles(outDir) {
for (const file of ["cesium-preview.css", "cesium-preview.js"]) {
const source = path.join(__dirname, file);
const files = [
[path.join(__dirname, "cesium-preview.css"), "cesium-preview.css"],
[path.join(__dirname, "cesium-preview.js"), "cesium-preview.js"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-breakdown.png"), "vehicle-breakdown.png"],
[path.join(__dirname, "..", "..", "assets", "preview", "vehicle-accident.png"), "vehicle-accident.png"],
];
for (const [source, file] of files) {
if (!fs.existsSync(source)) {
throw new Error(`Cesium preview support file '${file}' not found: ${source}`);
}
@@ -13,7 +18,7 @@ function writeCesiumPreviewSupportFiles(outDir) {
}
}
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) {
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null, previewDescriptorName = null) {
const previewConfig = {
areaId,
glbName,
@@ -22,6 +27,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
vehicleModelName,
vehicleModelNames,
trafficSignalsName,
previewDescriptorName,
};
return `<!doctype html>
<html lang="zh-CN">
@@ -68,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>
@@ -105,11 +126,11 @@ function escapeScriptJson(value) {
function previewSummary(area) {
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
return {
glbName: path.basename(area.outputs.glb),
metadataName: path.basename(area.outputs.metadata),
routeName: path.basename(area.outputs.vehicleRoute),
vehicleModelName: path.basename(area.outputs.vehicleModel),
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
glbName: "package/manifest.json",
metadataName: "package/manifest.json",
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
};
}

View File

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

View File

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

View File

@@ -16,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.
@@ -40,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);
@@ -54,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);
@@ -145,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));
@@ -164,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
: [{
@@ -176,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) {
@@ -187,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")) {
@@ -693,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) {
@@ -728,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) {
@@ -756,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) {
@@ -787,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") {

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

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

View File

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

View File

@@ -358,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}`);
@@ -487,4 +499,4 @@ function strokePolygon(points, width) {
return [...left, ...right, left[0]];
}
module.exports = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };
module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };

View File

@@ -78,6 +78,10 @@ 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 };
});
@@ -180,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,
@@ -190,6 +197,23 @@ function exportLayer(layerName, destination) {
});
}
function restoreIntersectionSurfaceIds(collection) {
for (const [index, feature] of collection.features.entries()) {
if (!feature || typeof feature !== "object") {
throw new Error(`intersection_surface feature ${index + 1} is invalid`);
}
const existingId = Number(feature.properties?.id);
if (Number.isInteger(existingId)) continue;
const fid = Number(feature.id);
if (!Number.isInteger(fid)) {
throw new Error(
`intersection_surface feature ${index + 1} is missing its osm2streets internal ID`,
);
}
feature.properties = { ...(feature.properties || {}), id: fid };
}
}
function readCollection(file, layerName) {
let parsed;
try {

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

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

View File

@@ -11,9 +11,12 @@ const { digestGltf } = require("./glb-digest");
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: [
@@ -51,6 +54,12 @@ assert.equal(
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/,
@@ -71,8 +80,13 @@ assert.equal(
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");

View File

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

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

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

View File

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

View File

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

View File

@@ -251,17 +251,31 @@ assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
assert.match(html, /id="toggleSignals"/);
assert.match(html, /id="vehicleInfoCard" class="hidden"/);
assert.match(html, /id="vehicleIncidentNote"/);
assert.match(html, /data-vehicle-status="breakdown"/);
assert.match(html, /data-vehicle-status="accident"/);
assert.match(html, /id="toggleBuildingGhost"/);
assert.match(html, /id="viewMode"/);
assert.match(html, /data-view-mode="inspect"/);
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
assert.match(buildAreaSource, /vehicleModelNames\.map\(\(name\) => `_preview\/\$\{name\}`\)/);
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
assert.match(previewRuntime, /asset\.category === "dynamic"/);
assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/);
assert.match(previewRuntime, /vehicleId: record\.id/);
assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/);
assert.match(previewRuntime, /accident \? "vehicle-accident\.png"/);
assert.match(previewRuntime, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
assert.match(previewRuntime, /routeEntity\.polyline\.material = accident \? Cesium\.Color\.RED : vehicle\.routeColor/);
assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const html = fs.readFileSync(path.join(__dirname, "workbench", "index.html"), "utf8");
assert.match(html, /id="width" type="number" min="1" step="0\.01"/);
assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/);
assert.match(html, /id="scene-preview" type="checkbox"/);
assert.match(html, /id="selected-junction" hidden/);
const app = fs.readFileSync(path.join(__dirname, "workbench", "app.js"), "utf8");
assert.match(app, /async function saveStagedChanges\(\)/);
assert.match(app, /if \(!await saveStagedChanges\(\)\) return;/);
assert.match(app, /function stageRoadOverride\(road, changes\)/);
assert.match(app, /road\.segmentId === selectedRoad\.segmentId/);
assert.match(app, /sidewalkLeft: rightInput\.checked, sidewalkRight: leftInput\.checked/);
assert.match(app, /function nativeSurfaceStyle\(feature\)/);
assert.match(app, /reference: new VectorLayer\(\{ source: source\(\), visible: false/);
assert.match(app, /scene mode must render fills only/);
assert.match(app, /scenePreviewToggle\.onchange/);
assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/);
assert.match(app, /function selectJunction\(feature\)/);
assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/);
assert.match(app, /外缘扩张倍率/);
assert.match(app, /最大外缘扩张/);
assert.match(app, /道路方向箭头/);
assert.match(app, /native-road-direction-arrow\/v1/);
assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/);
console.log("road workbench tests passed");

View File

@@ -2,7 +2,7 @@
"use strict";
const assert = require("assert");
const { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
function way(id, refs, tags) { return { id, refs, tags }; }
@@ -35,6 +35,9 @@ assert.ok(Math.abs((Math.min(...shaftXs) + Math.max(...shaftXs)) / 2) < 1e-9, "t
const rightXs = templateFor("right", manifest).flat().map(([x]) => x);
assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows");
assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches");
const nativePlacement = arrowRingsAt("left", [114, 30], [0, 1], manifest);
assert.ok(nativePlacement.length > 0 && nativePlacement.every((ring) => ring.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))));
assert.deepEqual(arrowRingsAt("slight_left", [114, 30], [0, 1], manifest), []);
const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest });
assert.equal(disabled.features.length, 0);
assert.equal(disabled.diagnostics[0].reason, "disabled");

View File

@@ -0,0 +1 @@
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}#dirty-state.dirty{color:#ffe08a;font-weight:700}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}button:disabled{cursor:default;opacity:.55}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}.segmented{display:flex;margin:0 0 8px}.segmented button{flex:1;border-radius:0;padding:6px 4px;font-size:12px}.segmented button+button{border-left:0}.segmented button:first-child{border-radius:3px 0 0 3px}.segmented button:last-child{border-radius:0 3px 3px 0}.segmented button.active{background:#286956;border-color:#286956;color:#fff}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}dl{display:grid;grid-template-columns:1fr auto;gap:5px 10px;margin:0}dt{color:#52615b}dd{margin:0;font-variant-numeric:tabular-nums}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}

198
scripts/workbench/app.js Normal file
View File

@@ -0,0 +1,198 @@
import Map from "/vendor/ol/Map.js";
import View from "/vendor/ol/View.js";
import VectorLayer from "/vendor/ol/layer/Vector.js";
import VectorSource from "/vendor/ol/source/Vector.js";
import GeoJSON from "/vendor/ol/format/GeoJSON.js";
import Feature from "/vendor/ol/Feature.js";
import LineString from "/vendor/ol/geom/LineString.js";
import Point from "/vendor/ol/geom/Point.js";
import Style from "/vendor/ol/style/Style.js";
import Fill from "/vendor/ol/style/Fill.js";
import Stroke from "/vendor/ol/style/Stroke.js";
import CircleStyle from "/vendor/ol/style/Circle.js";
import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js";
const geojson = new GeoJSON();
const areaLabel = document.querySelector("#area");
const status = document.querySelector("#status");
const form = document.querySelector("#road-form");
const hint = document.querySelector("#hint");
const roadName = document.querySelector("#road-name");
const movementSummary = document.querySelector("#movement-summary");
const laneConvention = document.querySelector("#lane-convention");
const selectedMovementPanel = document.querySelector("#selected-movement");
const movementDetail = document.querySelector("#movement-detail");
const directionSwitch = document.querySelector("#direction-switch");
const widthInput = document.querySelector("#width");
const lanesInput = document.querySelector("#lanes");
const leftInput = document.querySelector("#left");
const rightInput = document.querySelector("#right");
const evidence = document.querySelector("#evidence");
const diagnostics = document.querySelector("#diagnostics");
const diagnosticFilters = document.querySelector("#diagnostic-filters");
const summary = document.querySelector("#summary");
const connectionsBox = document.querySelector("#connections");
const addConnectionButton = document.querySelector("#add-connection");
const saveButton = document.querySelector("#save");
const compileButton = document.querySelector("#compile");
const dirtyState = document.querySelector("#dirty-state");
const scenePreviewToggle = document.querySelector("#scene-preview");
const selectedJunctionPanel = document.querySelector("#selected-junction");
const junctionDetail = document.querySelector("#junction-detail");
const directionArrowsToggle = document.createElement("label");
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle);
let state;
let selectedRoad = null;
let selectedMovement = null;
let selectedJunction = null;
let staged = [];
let manualFromEndpoint = null;
let diagnosticFilter = "all";
let scenePreview = false;
const source = () => new VectorSource();
const layers = {
reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }),
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
lanes: new VectorLayer({ source: source(), style: laneStyle }),
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
markings: new VectorLayer({ source: source(), style: markingStyle }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
};
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
map.addInteraction(select);
select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); if (junction) return selectJunction(junction); const marking = feature.get("provenance")?.startsWith("native-road-"); if (marking) { const road = roadForFeature(feature); const directionArrow = feature.get("provenance") === "native-road-direction-arrow/v1"; const turnArrow = feature.get("provenance") === "native-road-turn-arrow/v1"; const markingType = directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; selectRoad(road); evidence.textContent = JSON.stringify({ 标线类型: markingType, OSM道路: feature.get("osm_way_ids"), 方向: feature.get("direction"), 车道: feature.get("lane_index") || `${feature.get("left_lane_index")}${feature.get("right_lane_index")} 之间`, 转向: turnArrow ? feature.get("maneuver") : null, 道路内距离米: feature.get("distance_along_lane_meters") || null, 路口前距离米: feature.get("placement_distance_meters") || null, 来源: feature.get("provenance") }, null, 2); return message(`已选中${markingType}`); } const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null; selectRoad(roadForFeature(feature), undefined, movement); });
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
function message(text) { status.textContent = text; }
function updateDirtyState() {
const count = staged.length;
dirtyState.textContent = count ? `未保存修改 ${count}` : "所有修改已保存";
dirtyState.classList.toggle("dirty", count > 0);
saveButton.disabled = count === 0;
}
function roadLabel(road) { return road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(", ")}`; }
function osmDirectionLabel(road) { return road?.direction === "forward" ? "沿 OSM 方向" : "逆 OSM 方向"; }
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); }
function markingStyle() { return new Style({ fill: new Fill({ color: "#f5f6ee" }), stroke: new Stroke({ color: "#d9dacf", width: 1 }) }); }
function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines
// are editing aids, not physical seams, so scene mode must render fills only.
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
return feature.get("native_id")?.startsWith("junction:")
? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) })
: new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) });
}
function sidewalkSurfaceStyle() {
return scenePreview
? new Style({ fill: new Fill({ color: "#b7b9ad" }) })
: new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) });
}
function directionArrowFeature(geometry) { const middle = geometry.getCoordinateAt(.5); const before = geometry.getCoordinateAt(.48); const after = geometry.getCoordinateAt(.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < .01) return null; return new Feature({ geometry: new Point(middle), rotation: Math.atan2(after[1] - before[1], after[0] - before[0]) }); }
function refreshOsmDirection() { const directionSource = layers.osmDirection.getSource(); directionSource.clear(); if (!selectedRoad) return; const centerline = new LineString(selectedRoad.centerline).transform("EPSG:4326", "EPSG:3857"); const arrow = directionArrowFeature(centerline); if (arrow) directionSource.addFeature(arrow); }
function refreshSelectedMovement() { const movementSource = layers.selectedMovement.getSource(); movementSource.clear(); if (!selectedMovement?.geometryPublished || !effectiveConnectorEnabled({ connection_id: selectedMovement.connectionId, fromLaneId: selectedMovement.fromLaneId, toLaneId: selectedMovement.toLaneId })) return; const feature = layers.connectors.getSource().getFeatures().find((candidate) => candidate.get("movement_id") === selectedMovement.id); if (feature) movementSource.addFeature(new Feature({ geometry: feature.getGeometry().clone() })); }
function roadForFeature(feature) {
const properties = feature.getProperties();
const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
}
function junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : null; }
function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; }
function endpointsCompatible(from, to) { if (!from || !to || 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 readFeatures(collection) { return geojson.readFeatures(collection || { type: "FeatureCollection", features: [] }, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); }
function updateSources() {
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines));
layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows));
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]);
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
}
function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; }
function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
function selectRoad(road, note, movement = null) {
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") }));
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
roadName.textContent = `${roadLabel(road)}${osmDirectionLabel(road)}`;
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 当前方向节点顺序: road.sourceNodeIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
laneConvention.textContent = road.laneCount === 1 ? "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。本方向只有一条车道。" : "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。车道按行驶方向从左向右编号。";
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
}
function selectJunction(feature) {
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
message(`已选中路口OSM 节点 ${properties.osm_node_id}`);
}
function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; }
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}${osmDirectionLabel(targetRoad)}\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
function renderDirectionSwitch(road) {
directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; }
for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = osmDirectionLabel(item); button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); }
}
function renderMovementSummary(road) { const movements = state.compiled.movements?.filter((movement) => movement.fromRoadId === road.id && effectiveConnectorEnabled({ connection_id: movement.connectionId, fromLaneId: movement.fromLaneId, toLaneId: movement.toLaneId })) || []; const turns = movements.reduce((result, item) => { result[item.turn] = (result[item.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; const published = movements.filter((movement) => movement.geometryPublished).length; movementSummary.textContent = movements.length ? `已识别 ${movements.length} 个行驶动作,${published} 条已绘制路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join("")}` : "当前方向没有已识别的行驶动作"; }
function renderConnections(road) {
connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
if (!rows.length) connectionsBox.textContent = "当前方向到达终点后没有已识别的驶出道路。";
for (const connection of rows) { const target = state.compiled.model.roads.find((item) => item.id === state.compiled.model.endpoints.find((endpointItem) => endpointItem.id === connection.toEndpointId)?.roadId); if (!target) continue; const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); selectRoad(road, "有未保存修改:转向路径已即时更新"); }; label.append(input, ` ${turnName(road, target)}${roadLabel(target)}${osmDirectionLabel(target)}`); connectionsBox.append(label); renderLaneControls(connection); }
renderManualCandidates(endpoint, road);
}
function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } }
function renderLaneControls(connection) { const rows = state.compiled.movements?.filter((movement) => movement.connectionId === connection.id) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === row.toRoadId); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; const geometryNote = row.geometryStatus === "continuous" ? ",节点连续" : row.geometryStatus === "deferred-too-long" ? ",路径过长未绘制" : ""; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(row.toLaneId))}${osmDirectionLabel(targetRoad)}${geometryNote}`); connectionsBox.append(label); } }
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); layers.connectors.changed(); updateDirtyState(); }
function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); updateDirtyState(); }
function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }
addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); };
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; }
function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}${counts[filter]}`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["普通构面路口", comparison.nativeApproachEnvelopeJunctions], ["兜底构面路口", comparison.nativeFallbackJunctions], ["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio], ["道路方向箭头", comparison.nativeDirectionArrowFeatures], ["路口转向箭头", comparison.nativeTurnArrowFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); };
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); };
scenePreviewToggle.onchange = () => {
scenePreview = scenePreviewToggle.checked;
for (const input of document.querySelectorAll("[data-layer]")) {
const layer = input.dataset.layer;
if (["osm", "lanes", "reference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked);
}
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
layers.diagnostics.setVisible(!scenePreview);
layers.native.changed(); layers.sidewalks.changed();
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
};
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));

View File

@@ -0,0 +1,4 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>