Compare commits
35 Commits
main
...
feature/na
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8a3622b9 | |||
| 03a8d6159b | |||
| 1b9829d9ed | |||
| 822e6ef936 | |||
| 3eea12c6ea | |||
| 3d57655497 | |||
| 65cf8b96d9 | |||
| e1f3fc10ca | |||
| f43a122ff7 | |||
| 3e22a1872e | |||
| 1da985c932 | |||
| 817e361477 | |||
| 25287777f6 | |||
| a556c0fc97 | |||
| 1876472bf8 | |||
| 707e7f82f9 | |||
| 850e9eb344 | |||
| df81a22ce8 | |||
| b5fa4482f0 | |||
| ddd15f68b3 | |||
| aa5b1f85c8 | |||
| 761a52646c | |||
| f13d890ffb | |||
| 41a0e81a6f | |||
| eff7a01b2b | |||
| 0102ffbb3c | |||
| f2b8d79f5d | |||
| db0fba5bb5 | |||
| c925890848 | |||
| f385009043 | |||
| b4a81331e8 | |||
| e0cdf9e00b | |||
| 0790cbd0a9 | |||
| b68be063ad | |||
| d26921c6d1 |
@@ -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 |
|
||||
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
|
||||
|
||||
---
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
全部是 CommonJS(`package.json` 的 `"type": "commonjs"`),无构建步骤、无 TypeScript、
|
||||
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
|
||||
|
||||
## 可选 GLB 压缩阶段
|
||||
## 默认 GLB 压缩阶段
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
`compress` 是显式请求的替代产物阶段,不属于 `all`,也不覆盖默认 `<area-id>.glb`。
|
||||
它用于在已有 Cesium GLB 上生成并列压缩 GLB、metadata 和预览页。当前主路径是
|
||||
texture resize + WebP transcode。
|
||||
`compress` 是完整构建的默认阶段,位于 `cesium` 与 `package` 之间。它在
|
||||
`_pipeline/package-staging/` 的主 GLB 上执行 texture resize + WebP transcode,并替换 staged
|
||||
主 GLB 与 staged manifest;不触碰已发布的 `package/`,也不重写 preview HTML。未压缩版本只在
|
||||
`<areaDir>/_pipeline/compress-*` 临时目录中存在,成功或失败后都会清理。
|
||||
|
||||
`scripts/compress-glb.js` 是该阶段调用的低层脚本,也可单独运行做实验。
|
||||
|
||||
@@ -62,20 +63,17 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
|
||||
### 3. Contracts
|
||||
|
||||
- 输入必须是现有 `.glb` 文件;`--output` 必须不同于 `--input`。
|
||||
- 输出是并列压缩 GLB;默认构建产物不被替换。
|
||||
- `compress` 阶段依赖默认 `glb`、`metadata` 和 `cesiumPreview` 已存在。
|
||||
- 低层脚本仍输出到与输入不同的路径;`build-area.js` 使用临时输入/输出路径,避免原地压缩。
|
||||
- `compress` 阶段依赖 staged `glb` 与 `metadata` 已存在,并在全部临时交付文件生成和校验后
|
||||
以 `renameSync` 替换它们。
|
||||
- 默认链固定为 `gltf-transform resize -> gltf-transform webp`。
|
||||
- `compress` 阶段默认 `textureSize=768`;低层脚本单独运行时默认 `--texture-size 1024`。
|
||||
- `build-area.js` 默认写:
|
||||
- `<fileStem>-compressed-webp768.glb`
|
||||
- `<fileStem>-compressed-webp768.json`
|
||||
- `<fileStem>-compressed-webp768-cesium-preview.html`
|
||||
- 伴生 metadata 的 `asset` 与 `id="main"` 资产 URL 改为压缩 GLB 文件名;其余资产(包括
|
||||
`category="semantic"` 的 Cesium 分类检查 GLB)必须原样保留。
|
||||
- preview HTML 只替换 `window.OSM_ASSET_PREVIEW_CONFIG` 的 `glbName` /
|
||||
`metadataName` 和 loading 文案,不改 preview runtime。
|
||||
- `build-area.js` 不会写并列 `-compressed-*` 交付物;`package` 是唯一能发布静态文件到
|
||||
`package/` 的阶段。
|
||||
- staged manifest 的 `assets[*].uri` 必须保持 package-relative,如 `models/<area>.glb`;分类
|
||||
layer GLB 必须原样保留。
|
||||
- 成功时 stdout 打印 `GLB_COMPRESS_DONE <json>`,包含压缩前后大小、image /
|
||||
non-image bytes、结构计数、扩展、metadata / preview 输出路径。
|
||||
non-image bytes、结构计数、扩展与 metadata 输出路径。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -86,27 +84,24 @@ npm run compress:glb -- --input in.glb --output out.glb [options]
|
||||
| `--output` 等于 `--input` | 抛错,避免覆盖源 GLB |
|
||||
| 数值参数超范围 | 抛错并指出合法范围 |
|
||||
| `gltf-transform` 退出非零 | 抛错并带上 status / signal |
|
||||
| `--preview` 没有 `--metadata` | 抛错,因为 preview 必须指向存在的 metadata |
|
||||
| preview HTML 找不到配置块 | 抛错,不做猜测替换 |
|
||||
| `--stages compress` 但默认 preview 不存在 | `Cesium preview not found: <path>` |
|
||||
| `--stages compress` 但 staged manifest 不存在 | `Cesium metadata not found: <path>` |
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: `--stages cesium,compress` 先重导默认 GLB,再生成并列压缩产物。
|
||||
- Good: `--texture-size 768 --metadata --preview` 生成压缩 GLB、metadata、HTML,
|
||||
源 GLB 保持不变。
|
||||
- Good: 默认构建先导出 Cesium,再压缩 staged 资产,最后由 `package` 发布。
|
||||
- Good: `--stages compress` 对已有 staged 交付物重新压缩,临时源 GLB 不会留在输出目录。
|
||||
- Base: 只传 `--input --output` 生成压缩 GLB,不生成伴生文件。
|
||||
- Bad: 使用 `--meshopt` 后没有做 Cesium 兼容性验证就当默认产物发布。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `node --check scripts/compress-glb.js`
|
||||
- `npm run test:compress-glb`:断言压缩 metadata 只替换主资产,不丢失语义资产。
|
||||
- `npm run test:compress-glb`:断言压缩 manifest 只替换主资产,不丢失语义 layer。
|
||||
- `node --check scripts/build-area.js`
|
||||
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata --preview`
|
||||
- 对目标区域跑一次 `npm run build:area -- --stages compress`
|
||||
- `node scripts/glb-digest.js <compressed.glb>` 确认可解析结构和扩展
|
||||
- 浏览器/Cesium 预览压缩 HTML,确认 `EXT_texture_webp` 在目标环境可加载
|
||||
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata`
|
||||
- 对目标区域跑一次默认 `npm run build:area`
|
||||
- `node scripts/glb-digest.js <area>.glb` 确认可解析结构和扩展
|
||||
- 浏览器/Cesium 预览标准 HTML,确认 `EXT_texture_webp` 在目标环境可加载
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
@@ -116,10 +111,110 @@ Wrong:
|
||||
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a.glb
|
||||
```
|
||||
|
||||
Correct:
|
||||
Correct for the low-level script:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed-webp768.glb
|
||||
npm run compress:glb -- --input outputs/a/a.glb --output /tmp/a-compressed.glb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 可复用资产包发布
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
`package` 将已验证的 static staging 提升为下游可消费的唯一交付边界:
|
||||
`<areaDir>/package/`。它不复制大文件到第二个发布目录;成功时将整个 staging 目录原子 rename
|
||||
为 package。车辆、路线和动态信号属于预览能力,必须留在 `<areaDir>/_preview/`,不能进入 package。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/<area>.json --stages cesium,compress,package
|
||||
npm run build:area -- --config config/areas/<area>.json --stages preview
|
||||
node scripts/test-package-contract.js
|
||||
node scripts/test-package-examples.js
|
||||
```
|
||||
|
||||
下游入口永远是:
|
||||
|
||||
```text
|
||||
outputs/<area>/package/manifest.json
|
||||
```
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
发布 manifest 使用 `schema: "osm-asset-package/v1"`,至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "osm-asset-package/v1",
|
||||
"packageVersion": "1.0.0",
|
||||
"areaId": "example",
|
||||
"coordinateSystem": { "axes": "ENU", "units": "meters", "x": "east", "y": "north", "z": "up" },
|
||||
"placement": { "longitude": 114.3, "latitude": 30.5, "height": 0, "headingCorrectionDegrees": -90 },
|
||||
"bounds": { "minLon": 114.2, "minLat": 30.4, "maxLon": 114.4, "maxLat": 30.6 },
|
||||
"assets": [{
|
||||
"id": "main",
|
||||
"role": "scene",
|
||||
"category": "scene",
|
||||
"uri": "models/example.glb",
|
||||
"defaultLoad": true,
|
||||
"integrity": { "bytes": 123, "sha256": "..." }
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
- `coordinateSystem` 固定为 ENU meters,`placement` 是 WGS84 锚点与 heading correction。
|
||||
- 必须恰有一个 `role="scene"` / `category="scene"` / `defaultLoad=true` 主资产;roads、buildings、
|
||||
vegetation、water 是 `role="layer"` 且 `defaultLoad=false` 的可选语义层。
|
||||
- `assets[*].uri` 与 `runtime[*].uri` 只能是包内正向相对路径,禁止绝对路径、反斜杠和 `..`。
|
||||
- `runtime` 声明可编程运行时资产;当前至少包含 `traffic-signals` 锚点 JSON、
|
||||
`traffic-signals-dynamic` 灯珠 GLB,以及两个 `traffic-signals-countdown-*` 倒计时 GLB。
|
||||
它们是资产包的一部分,不属于 `_preview/`。
|
||||
- `package` 在晋升前调用 `validateManifest()`;再为每个模型写 bytes 与 SHA-256,然后复验。
|
||||
- `package` 是唯一拥有最终 package file records 的 stage。`cesium` / `compress` 只能拥有 staging
|
||||
或计算摘要,不能留下指向最终 package 文件的 outputs record。
|
||||
- 下游代码和 preview 都从 manifest URL 解析 `assets[*].uri`。参考
|
||||
`examples/cesium-asset-package.js` 与 `examples/three-asset-package.js`。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| staging manifest 缺失或无效 JSON | `package` 失败,不替换上次成功 package |
|
||||
| URI 是绝对路径、含 `..` 或 `\\` | `validateManifest()` 失败 |
|
||||
| ENU / WGS84 字段缺失或越界 | `validateManifest()` 失败 |
|
||||
| 没有或多于一个 scene asset | `validateManifest()` 失败 |
|
||||
| manifest 声明的模型文件缺失 | `validateManifest()` 失败 |
|
||||
| rename 发布异常 | 尝试恢复 `<package>.previous`,保留原 package |
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: 下游只带走 `package/`,按 manifest 的 WGS84 placement 放置主模型,按需加载 layer。
|
||||
- Base: 只有 main scene 的区域仍是合法 package;没有几何的语义类别不写空 GLB。
|
||||
- Bad: 将 `_preview/` 的车辆模型或路线加进 package;它们是本项目的验证运行时,信号灯 runtime 则必须发布。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `npm run test:package-contract`:schema、URI、placement、唯一 scene 和模型存在性。
|
||||
- `npm run test:package-examples`:Cesium / Three.js resolver 都相对 manifest 解析 URI。
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress,package,preview`
|
||||
- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
|
||||
- 将 `package/` 复制到其他目录后,重新运行 manifest validator;所有 `assets[*].uri` 必须仍能解析。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
Wrong:
|
||||
|
||||
```js
|
||||
const modelUrl = asset.uri; // Relative to preview HTML by accident.
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```js
|
||||
const modelUrl = new URL(asset.uri, new URL(manifestUrl, window.location.href)).href;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -272,6 +367,53 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
|
||||
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
|
||||
```
|
||||
|
||||
## Native 道路标线
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
|
||||
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
|
||||
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
|
||||
GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
|
||||
|
||||
### 2. 图层契约
|
||||
|
||||
| 文件 | 语义 | 必需 provenance | Blender material layer |
|
||||
|---|---|---|---|
|
||||
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
|
||||
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
|
||||
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
|
||||
|
||||
方向箭头必须带 `road_id`、`lane_id`、`osm_way_ids`、`direction`、`lane_index`、
|
||||
`sequence`、`distance_along_lane_meters` 和 `placement_interval_meters`。转向箭头
|
||||
必须带 `maneuver` 与 `placement_distance_meters`。两者不能共用 provenance 或假装为
|
||||
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
|
||||
|
||||
### 3. 放置与错误矩阵
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
|
||||
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
|
||||
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
|
||||
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
|
||||
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
|
||||
|
||||
### 4. 必需测试
|
||||
|
||||
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距,以及无标签道路
|
||||
不生成路口转向箭头。
|
||||
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
|
||||
`direction_arrows` 与 `turn_arrows`,且不运行 `package`。
|
||||
|
||||
### 5. 错误与正确写法
|
||||
|
||||
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`。
|
||||
|
||||
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
|
||||
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
|
||||
|
||||
## 斑马线与停止线来源
|
||||
|
||||
### 1. 范围与触发条件
|
||||
@@ -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 和 violations;warnings 来自同一个预算评估
|
||||
|
||||
`cesium` 会调用 preview 生成函数,但 preview HTML / route / vehicle model 的 freshness
|
||||
所有权属于独立 `preview` manifest。否则单跑 `--stages preview` 会把 Cesium manifest
|
||||
错误判 stale。
|
||||
`cesium` 只拥有 staging 内的静态模型与 manifest;完成后必须依次由 `compress`、`package`
|
||||
交付。preview HTML / route / vehicle model 的 freshness 所有权属于独立 `preview` manifest。
|
||||
|
||||
`compress` manifest 不记录最终文件 records:它们位于可被 `package` rename 的 staging 目录。
|
||||
`package` manifest 是唯一记录 `package/manifest.json`、主 GLB 与 package 目录的 stage manifest,
|
||||
避免发布后 compress manifest 因路径迁移而立刻 stale。
|
||||
|
||||
`package` manifest:
|
||||
|
||||
- `inputs.stagingManifest`:发布后的同一 manifest 文件记录
|
||||
- `outputs.packageDir`、`outputs.manifest`、`outputs.primaryGlb`
|
||||
- `summary.assets` 与 `summary.packageDir`
|
||||
|
||||
### Preview Assembly Boundary
|
||||
|
||||
@@ -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,且独立入口的独立性是刻意的 |
|
||||
|
||||
@@ -120,6 +120,56 @@ SCENE_LAYERS.forEach((layer, index) => {
|
||||
|
||||
## Blender 调用
|
||||
|
||||
### macOS Blender 4.5 的 Metal 启动兼容
|
||||
|
||||
#### 1. Scope / Trigger
|
||||
|
||||
`export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。
|
||||
|
||||
#### 2. Signatures
|
||||
|
||||
Cesium 阶段的调用参数必须包含:
|
||||
|
||||
```text
|
||||
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ...
|
||||
```
|
||||
|
||||
#### 3. Contracts
|
||||
|
||||
`--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。
|
||||
|
||||
#### 4. Validation & Error Matrix
|
||||
|
||||
| 情况 | 结果 |
|
||||
|---|---|
|
||||
| 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 |
|
||||
| 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 |
|
||||
|
||||
#### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。
|
||||
- Base: Blender 场景阶段未受影响时,不额外改变其启动参数。
|
||||
- Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。
|
||||
|
||||
#### 6. Tests Required
|
||||
|
||||
- `npm run test:build-stages` 断言导出参数仍包含 workaround。
|
||||
- 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。
|
||||
|
||||
#### 7. Wrong vs Correct
|
||||
|
||||
Wrong:
|
||||
|
||||
```text
|
||||
--background --python blender/export_cesium.py
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```text
|
||||
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py
|
||||
```
|
||||
|
||||
### 两种调用姿势
|
||||
|
||||
| 阶段 | 参数 | 出处 |
|
||||
|
||||
@@ -51,17 +51,21 @@ config/areas/<id>.json
|
||||
│ → _pipeline/stages/blender.manifest.json
|
||||
│
|
||||
├─[cesium]────────▶ Blender + blender/export_cesium.py
|
||||
│ 读 .blend → <id>.glb + <id>.json
|
||||
│ → 并自动执行 preview
|
||||
│ 读 .blend → _pipeline/package-staging/models/<id>.glb
|
||||
│ + staged manifest,动态预览 GLB 写入 _preview/
|
||||
│ → _pipeline/stages/cesium.manifest.json
|
||||
│
|
||||
├─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
│ + 拷贝 lib/cesium-preview.{js,css}
|
||||
│ + 车辆巡航路线与模型
|
||||
│ → _pipeline/stages/preview.manifest.json
|
||||
├─[compress]──────▶ 压缩 staging 内主 GLB 并更新 staged manifest
|
||||
│ → _pipeline/stages/compress.manifest.json
|
||||
│
|
||||
└─[compress]──────▶ 生成并列压缩 GLB / metadata / preview
|
||||
→ _pipeline/stages/compress.manifest.json
|
||||
├─[package]───────▶ 校验 manifest 与全部静态模型,原子发布 package/
|
||||
│ → package/manifest.json + package/models/*.glb
|
||||
│ → _pipeline/stages/package.manifest.json
|
||||
│
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
│ + 拷贝 lib/cesium-preview.{js,css}
|
||||
+ _preview/ 车辆巡航路线、模型、动态信号
|
||||
│ → _pipeline/stages/preview.manifest.json
|
||||
```
|
||||
|
||||
**阶段之间只通过磁盘产物耦合**,不传内存状态。这是单跑任意阶段能work 的前提。
|
||||
@@ -87,7 +91,7 @@ config/areas/<id>.json
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|---|---|---|
|
||||
| `build-area.js` | 约 530 | 主入口:区域配置读取、阶段调度、preview 文件写入和 stage manifest ownership |
|
||||
| `build-area.js` | 主入口:区域配置读取、阶段调度、package 发布、preview 文件写入和 stage manifest ownership |
|
||||
| `diagnose-area.js` | 36 | 快速诊断入口:调用共享 area diagnostics 并打印完整报告 |
|
||||
| `check-area.js` | 74 | 区域质量门入口:调用共享 area diagnostics,输出 PASS/FAIL 并设置退出码 |
|
||||
| `lib/area-diagnostics.js` | 776 | 共享区域诊断事实源:OSM、产物、metadata、stage manifest、GLB digest 和质量门分类 |
|
||||
@@ -99,6 +103,7 @@ config/areas/<id>.json
|
||||
| `lib/vehicle-route.js` | 约 180 | 从 OSM 提取确定性预览巡航路线 |
|
||||
| `lib/vehicle-model.js` | 约 150 | 生成内嵌 buffer 的预览车辆 glTF |
|
||||
| `lib/area-preview.js` | 约 110 | 复制 preview runtime、生成 HTML 与转义配置注入 |
|
||||
| `lib/package-contract.js` | package manifest 校验、相对 URI 与 SHA-256 完整性记录 |
|
||||
| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) |
|
||||
| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) |
|
||||
| `parity.js` | 270 | 产物一致性校验驱动 |
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
## 定位
|
||||
|
||||
预览层是**验证性的,不是产物本身**。它加载 `cesium` 阶段导出的 `.glb` + `.json`,
|
||||
用来确认资产在真实 Cesium 里的样子。改这一层**不会**改变 Blender/GLB 主资产。
|
||||
预览层是**验证性的,不是产物本身**。它加载 `package/manifest.json`,用来确认已发布资产
|
||||
在真实 Cesium 里的样子。改这一层**不会**改变 package 内的 Blender/GLB 静态资产。
|
||||
|
||||
车辆巡航同理——README 里写明它是"用于验证高精度巡航可用性的预览层功能"。
|
||||
|
||||
@@ -53,9 +53,14 @@ const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4
|
||||
**加一个新的可配置项**:`cesiumPreviewHtml()` 里加进注入的 JSON,JS 侧从 `config` 读,
|
||||
两边都要动。
|
||||
|
||||
`build-area.js` 只保留 GLB / metadata 依赖检查、写入顺序和 preview manifest ownership;
|
||||
`glbName` 与 `metadataName` 都是 `package/manifest.json`。浏览器读取 package manifest 后,
|
||||
每个 `assets[*].uri` 必须相对**manifest 文件**解析,绝不能相对 preview HTML 解析;否则将
|
||||
错误请求 `outputs/<area>/models/...` 而不是 `outputs/<area>/package/models/...`。
|
||||
|
||||
`build-area.js` 只保留已发布 package / 动态输入的依赖检查、写入顺序和 preview manifest ownership;
|
||||
不要把 HTML 模板、runtime copy 或转义实现移回阶段调度器。路线 JSON 与车辆 glTF 分别由
|
||||
`vehicle-route.js` 和 `vehicle-model.js` 生成,二者都是不启动外部工具的 Node 模块。
|
||||
交通信号灯 runtime 则从 package manifest 的 `runtime` 读取;预览只负责驱动其状态,不拥有这些文件。
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal file
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal file
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/pipeline/index.md","reason":"Check command ownership, disk artifact boundaries, and legacy pipeline compatibility."}
|
||||
{"file":".trellis/spec/preview/index.md","reason":"Check browser workbench state, error handling, and no-build browser constraints."}
|
||||
{"file":".trellis/spec/config/index.md","reason":"Check new area output/config normalization and compatibility."}
|
||||
110
.trellis/tasks/08-13-native-road-compiler/design.md
Normal file
110
.trellis/tasks/08-13-native-road-compiler/design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Native Road Compiler Workbench Design
|
||||
|
||||
## Architecture
|
||||
|
||||
The native compiler is an additive pipeline path. It owns a separate output
|
||||
directory under an area and never writes into `osm2streets_web_out/`.
|
||||
|
||||
```text
|
||||
OSM XML + native-road-overrides.json
|
||||
|
|
||||
v
|
||||
Canonical Road Model
|
||||
roads / endpoints / junction candidates / provenance
|
||||
|
|
||||
v
|
||||
Native Geometry Compiler
|
||||
road surfaces / initial junction surfaces / diagnostics
|
||||
|
|
||||
+--> native-road/compiled.json
|
||||
+--> native-road/layers/*.geojson
|
||||
+--> native-road/diagnostics.json
|
||||
+--> native-road/comparison.json
|
||||
|
|
||||
v
|
||||
Road Workbench HTTP service
|
||||
browser map + inspect/edit/save API
|
||||
```
|
||||
|
||||
The canonical model is the authority. Render layers, browser display data, and
|
||||
future Blender compatibility adapters are derived from it.
|
||||
|
||||
## Commands And Ownership
|
||||
|
||||
- `npm run road:compile -- --config <area-config>` performs no browser work.
|
||||
It reads OSM plus the persisted override file, writes a staged native-road
|
||||
result, validates it, and atomically promotes the result directory.
|
||||
- `npm run road:workbench -- --config <area-config>` compiles first unless
|
||||
`--no-compile` is supplied, then starts a local HTTP server scoped to that
|
||||
one area.
|
||||
- The server exposes read-only compiler artifacts and one explicit save API
|
||||
for validated overrides. It does not expose arbitrary filesystem paths.
|
||||
- Existing `build:area`, `intermediates`, QGIS, Blender, Cesium, and package
|
||||
paths remain unchanged in the first iteration.
|
||||
|
||||
## Data Contracts
|
||||
|
||||
### Canonical road model
|
||||
|
||||
Each road direction carries a stable ID derived from OSM identifiers, source
|
||||
way IDs, endpoint node IDs, centerline, explicit/inferred attributes, applied
|
||||
override IDs, and diagnostics. Junction candidates likewise use their OSM node
|
||||
ID when available. Values include provenance such as `tag:lanes:forward`,
|
||||
`inferred:highway-default`, or `override:<id>`.
|
||||
|
||||
Each `Movement` is a stable semantic record joining a connection, source and
|
||||
target road/lane, turn class, provenance, and an optional connector geometry.
|
||||
`geometryStatus="connector"` publishes a connector curve;
|
||||
`geometryStatus="continuous"` means the lane centerlines meet at the node and
|
||||
does not invent a zero-length curve; `deferred-too-long` retains the movement
|
||||
while withholding unsafe geometry.
|
||||
|
||||
### Override file
|
||||
|
||||
`<area>/native-road-overrides.json` is versioned and human-reviewable. It
|
||||
contains an array of uniquely identified changes whose targets are stable road
|
||||
or endpoint IDs. Supported v1 records are `road` parameter overrides and
|
||||
`junction-connection` decisions. The save endpoint validates schema, target
|
||||
existence, finite values, and duplicate/conflicting edits before atomic write.
|
||||
|
||||
### Compiler artifacts
|
||||
|
||||
`<area>/native-road/compiled.json` is the workbench's single read model and
|
||||
contains the canonical `movements` list.
|
||||
`layers/` contains generated GeoJSON with source/provenance properties.
|
||||
`diagnostics.json` contains severity, stable subject ID, source IDs, rule,
|
||||
message, and optional geometry. `comparison.json` reports counts and coverage
|
||||
against available osm2streets layers; it does not claim quality solely from
|
||||
visual differences.
|
||||
|
||||
## Browser Workbench
|
||||
|
||||
The browser uses OpenLayers as its sole GIS runtime, served directly from the
|
||||
local allowlisted `node_modules` packages with a browser import map. The map
|
||||
renders fit-to-data OSM centerlines, native surfaces, optional osm2streets
|
||||
reference layers, diagnostics, selected-object provenance, movements, and
|
||||
overrides. This provides mature map selection and hit detection without a
|
||||
framework or bundler.
|
||||
|
||||
The user can select a road or endpoint, edit only v1 fields, inspect the
|
||||
resulting override record, explicitly save it, and recompile/reload. Saved
|
||||
state is visibly differentiated from unsaved state. The workbench must not
|
||||
offer freehand final-polygon editing, since that would break reproducibility.
|
||||
|
||||
## Geometry And Validation
|
||||
|
||||
V1 produces road segments from projected centerline offsets and terminal
|
||||
cross-sections. It only generates a junction surface when endpoints satisfy
|
||||
the supported ordinary T/cross shape and geometry checks; otherwise it emits a
|
||||
diagnostic rather than inventing an invalid polygon. Validation detects
|
||||
dangling endpoints, unclosed/self-intersecting rings, non-finite coordinates,
|
||||
unsupported multi-level intersections, and source/topology ambiguity. Small
|
||||
numerical cleanup may be explicit and recorded; semantic failures are never
|
||||
silently repaired.
|
||||
|
||||
## Compatibility And Rollout
|
||||
|
||||
The first compiler's layers use existing render-layer names where meaningful,
|
||||
but are stored separately. A later, explicitly enabled Blender provider option
|
||||
may consume native layers after comparison gates pass. Delete/replace behavior
|
||||
is out of scope; rollback is selecting the existing osm2streets pipeline.
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/pipeline/index.md","reason":"Native compiler commands, artifacts, and area config extend the Node pipeline while preserving legacy stages."}
|
||||
{"file":".trellis/spec/preview/index.md","reason":"The browser workbench is a new DOM runtime and must follow local preview loading and state conventions where applicable."}
|
||||
{"file":".trellis/spec/config/index.md","reason":"New native-road output paths and config behavior extend the normalized area contract."}
|
||||
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal file
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add area output/config normalization and command entrypoints for the native
|
||||
compiler, preserving existing stage behavior and paths.
|
||||
2. Implement a shared OSM road parser and canonical road/endpoints model with
|
||||
source provenance, explicit versus inferred properties, and stable IDs.
|
||||
3. Implement v1 override schema, validation, load/apply behavior, atomic save,
|
||||
and focused unit tests.
|
||||
4. Implement projected road segment geometry, supported T/cross junction
|
||||
detection, native GeoJSON artifact emission, diagnostics, and comparison
|
||||
summary.
|
||||
5. Implement a dependency-free local workbench server and browser UI with
|
||||
selection, provenance display, v1 parameter/topology editing, explicit save,
|
||||
compile/reload, and error states.
|
||||
6. Add native compiler tests using focused fixtures plus nantaizi analysis;
|
||||
run existing relevant Node tests to confirm legacy behavior remains intact.
|
||||
7. Compare nantaizi and at least one supplied problematic OSM sample. Record
|
||||
metrics, unsupported cases, and follow-up work in task research.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run test:build-stages
|
||||
npm run test:preflight
|
||||
npm run test:preview-assets
|
||||
```
|
||||
|
||||
Browser validation includes loading the workbench, editing a road parameter,
|
||||
saving, verifying the override file, recompiling, reloading, and confirming
|
||||
provenance identifies the saved override.
|
||||
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal file
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Native road compiler workbench
|
||||
|
||||
## Goal
|
||||
|
||||
Build an incremental native road compiler for Chinese urban and campus OSM
|
||||
data that can progressively exceed osm2streets in geometry quality,
|
||||
explainability, and repeatable correction. The existing osm2streets pipeline
|
||||
must remain usable while the native compiler is developed and compared.
|
||||
|
||||
The first deliverable is a browser-based Road Workbench. It must expose the
|
||||
native compiler's source data, generated geometry, and diagnostics, allow
|
||||
users to make small semantic/topology corrections, persist those corrections
|
||||
as versionable overrides, and reload them automatically in later runs.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Nantaizi currently works relatively well because its OSM data received
|
||||
deliberate supplemental tagging; it still has missing boundaries and
|
||||
polygons that cannot be closed.
|
||||
- Other tested OSM inputs expose osm2streets sensitivity to input structure
|
||||
and leave too much opaque, final-polygon repair work in QGIS.
|
||||
- Existing Blender consumes the nine GeoJSON render layers from
|
||||
`osm2streets_web_out/`; QGIS GeoPackage edits can currently be reimported
|
||||
only as a whole batch.
|
||||
- The repository has no existing interactive browser editing service. Existing
|
||||
Cesium preview is a static, generated verification page.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Add a native-road-compiler path without replacing or regressing the
|
||||
existing osm2streets path.
|
||||
- R2: Parse OSM into a canonical, source-traceable road model with stable
|
||||
references to OSM ways and nodes, explicit values versus inferred values,
|
||||
and diagnostics.
|
||||
- R3: Compile at least ordinary road segments and the initial supported
|
||||
junction subset into the existing render-layer contract, allowing existing
|
||||
Blender/Cesium consumers to be reused.
|
||||
- R4: Provide a browser Road Workbench that overlays raw OSM topology,
|
||||
generated geometry, osm2streets comparison geometry when available, and
|
||||
compiler diagnostics.
|
||||
- R5: The workbench must permit scoped user adjustments and save them to an
|
||||
area-local, human-reviewable override file. Future compile and workbench
|
||||
runs must load that file automatically.
|
||||
- R6: Each generated object and diagnostic must be traceable to OSM source
|
||||
IDs, compiler rule/inference evidence, and relevant override IDs.
|
||||
- R7: Validate topology and geometry before publishing generated layers;
|
||||
report unresolved semantic errors instead of silently disguising them as
|
||||
geometric repair.
|
||||
- R8: Develop against nantaizi plus problem inputs and report native versus
|
||||
osm2streets comparison metrics.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- First implementation targets Chinese urban/campus roads, ordinary road
|
||||
segments, T/cross junctions, directed/multi-lane roads, and data already
|
||||
tagged in nantaizi where possible.
|
||||
- Existing Blender, Cesium export, package format, building, vegetation, and
|
||||
water generators are out of scope unless a compatibility adapter requires a
|
||||
narrowly scoped change.
|
||||
- Directly editing final render polygons is not the intended correction model;
|
||||
generated layers remain derived output.
|
||||
- Complex interchanges, arbitrary multilayer junctions, and full worldwide OSM
|
||||
coverage are deferred until driven by concrete samples.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A native compile command produces a canonical road model, generated
|
||||
layers, diagnostics, and comparison artifacts for a configured area without
|
||||
changing the osm2streets output path.
|
||||
- [ ] A browser command serves a Road Workbench for an area and clearly shows
|
||||
source topology, generated output, diagnostics, provenance, and saved
|
||||
overrides.
|
||||
- [ ] A user can make the agreed first-scope override edits in the browser,
|
||||
save them explicitly, and receive a durable area-local override artifact.
|
||||
- [ ] Re-running compile or reopening the workbench applies saved overrides
|
||||
automatically and exposes their provenance.
|
||||
- [ ] The compiler reports invalid/unclosed geometry, dangling road ends,
|
||||
and unresolved junction/lane ambiguity with source IDs.
|
||||
- [ ] Nantaizi and at least one known problematic area can run through the
|
||||
native analysis/preview path, with comparison metrics captured rather than
|
||||
a claim based only on visual inspection.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- The first browser editing surface supports road parameters (width, directed
|
||||
lane counts, left/right sidewalk state) plus junction endpoint
|
||||
connect/disconnect decisions.
|
||||
- Turn restrictions, stop lines, and crosswalk placement are deferred until
|
||||
the compiler has a validated road/junction editing loop.
|
||||
- Overrides are a versioned, human-reviewable JSON artifact owned by the area,
|
||||
not edits to generated polygon layers.
|
||||
- Native output and osm2streets output remain parallel during development;
|
||||
neither silently overwrites the other.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Two-Area Native Road Comparison
|
||||
|
||||
## Runs
|
||||
|
||||
2026-08-14:
|
||||
|
||||
```bash
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/hanyang-block.json --stages intermediates
|
||||
npm run road:compile -- --config config/areas/hanyang-block.json
|
||||
```
|
||||
|
||||
`comparison.json` is a coverage and diagnostic record. Feature counts are not
|
||||
a geometry-quality score: osm2streets and the native compiler segment roads at
|
||||
different levels.
|
||||
|
||||
| Area | Directional roads | Native surfaces | Native junctions | Movements | Internal ends | Manual candidates | osm2streets road surfaces |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| nantaizi-lake-innovation-valley | 34 | 19 | 6 | 46 | 4 | 2 | 50 |
|
||||
| hanyang-block | 475 | 352 | 20 | 369 | 161 | 83 | 1826 |
|
||||
|
||||
## Observed Failure Modes
|
||||
|
||||
The hanyang osm2streets/QGIS run completed, but its log reported repeated:
|
||||
|
||||
- roads trimmed into oblivion;
|
||||
- degenerate intersections that could not be collapsed because layers, names,
|
||||
highway types, or lane specifications differ;
|
||||
- intersection polygon requests with no roads.
|
||||
|
||||
The native compiler did not hide the related uncertainty. It reported 161
|
||||
internal road ends. Eighty-three have one or more nearby, direction-compatible
|
||||
candidate departures within 35 metres; these remain suggestions for explicit
|
||||
review rather than automatic topology edits. The initial five one-way junctions
|
||||
that lacked connector curves now publish road-surface envelopes based on their
|
||||
semantic movements. Across hanyang, 369 movements are identified: 243 require
|
||||
a connector curve, while 126 are continuous at their OSM node and intentionally
|
||||
have no separate geometry.
|
||||
|
||||
## Resulting Priorities
|
||||
|
||||
1. Keep manual candidate suggestions and semantic overrides as the correction
|
||||
path for near-miss topology. Do not bulk-connect candidates.
|
||||
2. Expand ordinary junction support from 3/4 physical approaches only after
|
||||
identifying a repeated unsupported topology; one-way movements that are
|
||||
continuous at a node are already supported without fake connector geometry.
|
||||
3. Add an inspectable movement artifact so turn geometry is not the only
|
||||
representation of a road-to-road movement.
|
||||
4. Use a visual review of a few explicit hanyang diagnostics before changing
|
||||
connection-distance or road-class rules.
|
||||
28
.trellis/tasks/08-13-native-road-compiler/task.json
Normal file
28
.trellis/tasks/08-13-native-road-compiler/task.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "native-road-compiler",
|
||||
"name": "native-road-compiler",
|
||||
"title": "Native road compiler workbench",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-13",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-14-native-road-lane-markings"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Verify package stage ordering, manifest freshness, and external-tool boundaries."}
|
||||
{"file":".trellis/spec/config/index.md","reason":"Verify all new output paths remain centralized in area-config."}
|
||||
{"file":".trellis/spec/preview/index.md","reason":"Verify preview remains a consumer of package assets, not part of the published package."}
|
||||
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Assess intentional output-contract changes and asset verification scope."}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Asset Package Contract Design
|
||||
|
||||
## Product Boundary
|
||||
|
||||
`outputs/<area-id>/` remains the build workspace. Its `package/` child is the only
|
||||
publishable subtree and may be copied unchanged to another project. The package has
|
||||
no dependency on the parent directory, repository checkout, absolute local paths,
|
||||
QGIS, Blender, or the Cesium preview.
|
||||
|
||||
```
|
||||
outputs/<area-id>/
|
||||
package/ # publishable boundary
|
||||
manifest.json # asset-package/v1
|
||||
models/
|
||||
<area-id>.glb # compressed complete static scene
|
||||
roads.glb
|
||||
buildings.glb
|
||||
vegetation.glb
|
||||
water.glb
|
||||
<area-id>-cesium-preview.html # local verification only
|
||||
_preview/ # vehicles, routes, dynamic signal runtime
|
||||
_pipeline/ # staging, manifests and build diagnostics
|
||||
osm2streets_web_out/, *.gpkg, *.qgz, *.blend, *.png
|
||||
```
|
||||
|
||||
The package does not duplicate the GLBs: Cesium export and compression target the
|
||||
package model paths. Build staging lives under `_pipeline/` and is removed before a
|
||||
successful package is published.
|
||||
|
||||
## Manifest v1
|
||||
|
||||
The JSON root uses `schema: "osm-asset-package/v1"`. Required top-level fields are:
|
||||
|
||||
| Field | Contract |
|
||||
|---|---|
|
||||
| `schema`, `packageVersion`, `areaId` | Stable identity and schema evolution point |
|
||||
| `coordinateSystem` | Literal local ENU contract: X east, Y north, Z up, meters |
|
||||
| `placement` | WGS84 anchor longitude/latitude/height plus heading correction degrees |
|
||||
| `bounds` | WGS84 `minLon`, `minLat`, `maxLon`, `maxLat` |
|
||||
| `assets` | Deterministic list of declared publishable assets |
|
||||
| `sceneStats` | Optional descriptive OSM-derived counts, not loading-critical |
|
||||
|
||||
Each asset has `id`, `role`, `category`, `uri`, and `defaultLoad`. `uri` is a
|
||||
forward-slash relative path inside `package/`; it must not be absolute, start with
|
||||
`/`, contain `..`, or resolve outside the package. Roles are `scene` for the complete
|
||||
static scene and `layer` for separately loadable semantic subsets. Categories are
|
||||
fixed in v1: `scene`, `roads`, `buildings`, `vegetation`, `water`.
|
||||
|
||||
`models/<area-id>.glb` is the only `scene` entry and defaults to load. Layer GLBs are
|
||||
optional alternatives for selective loading and default off; consumers must not load
|
||||
both the complete scene and overlapping layers unless intentionally composing them.
|
||||
|
||||
The manifest deliberately excludes `source_osm`, `source_geojson`, arbitrary Cesium
|
||||
JavaScript, vehicle routes, vehicle models, dynamic signal states, countdown models,
|
||||
preview runtime paths, build timestamps and desktop paths. File digests and sizes may
|
||||
be added under an optional `integrity` object only after they are computed from final
|
||||
compressed files.
|
||||
|
||||
## Pipeline Design
|
||||
|
||||
### 1. Static export seed
|
||||
|
||||
`blender/export_cesium.py` continues to own static GLB generation, WGS84 bounds,
|
||||
anchor and semantic collection selection. It must emit data suitable for a manifest
|
||||
seed, not a preview metadata document. Static traffic-signal geometry already in the
|
||||
main scene remains part of the static scene; animated traffic signal and countdown
|
||||
collections are not package assets.
|
||||
|
||||
### 2. Package staging and compression
|
||||
|
||||
The build orchestrator owns output paths through `normalizeAreaConfig()`. It adds
|
||||
explicit package paths and exports static GLBs to a package staging directory beneath
|
||||
`_pipeline/`. Compression works only on staged package files and rewrites the staged
|
||||
manifest's main GLB URI after successful compression. It never needs to copy or alter
|
||||
preview HTML.
|
||||
|
||||
After path validation, file existence checks, and manifest validation, the package
|
||||
stage promotes the whole staging directory to `outputs/<area-id>/package/`. Promotion
|
||||
is directory-level and replaces a previous package only after the new one is valid.
|
||||
On failure, the previous published package remains usable and staging is retained or
|
||||
reported for diagnosis according to existing pipeline failure conventions.
|
||||
|
||||
### 3. Preview adapter
|
||||
|
||||
The existing Cesium preview remains a verification tool outside the package. It reads
|
||||
the package manifest via its relative path, then loads preview-only route, vehicle and
|
||||
dynamic-signal descriptors from `_preview/`. Dynamic traffic assets are declared in a
|
||||
preview-specific descriptor, never appended to package `assets`. This preserves
|
||||
existing high-precision cruise validation without making it a downstream requirement.
|
||||
|
||||
### 4. Stage and compatibility behavior
|
||||
|
||||
Canonical order becomes `intermediates/reimport -> blender -> cesium -> compress ->
|
||||
package -> preview`. `package` is included in the full default build and is callable
|
||||
explicitly to validate/publish existing staged static exports. `preview` stays an
|
||||
optional/verification stage, not a package dependency.
|
||||
|
||||
Existing `npm run build:area -- --config ... --stages ...` remains supported. Root
|
||||
level legacy GLB/JSON/HTML files are not deleted by the migration; they are not read
|
||||
as a fallback by the new package contract. A clean full build produces the package as
|
||||
the canonical downstream asset source.
|
||||
|
||||
## Consumer Examples
|
||||
|
||||
Examples live in repository source, outside individual packages:
|
||||
|
||||
- Cesium: fetch `manifest.json`, derive the ENU frame from `placement`, apply heading
|
||||
correction, then load `assets` by `uri`.
|
||||
- Three.js: load the selected model URL relative to the manifest URL; expose the same
|
||||
ENU placement object to the host application's georeferencing adapter. It must not
|
||||
imply that Three.js alone converts ENU to WGS84.
|
||||
|
||||
Both examples load the complete scene and demonstrate selecting a single `layer`.
|
||||
They validate that URLs are resolved relative to the manifest rather than the current
|
||||
page or repository root.
|
||||
|
||||
## Validation and Rollback
|
||||
|
||||
Unit tests validate schema fields, category/role legality, package-relative URI rules,
|
||||
and rejection of omitted/extra files. An integration fixture constructs a minimal
|
||||
package and verifies that Cesium and Three.js example resolvers choose identical
|
||||
relative URLs and placement values. A target-area package test checks final compressed
|
||||
GLB parsing and manifest references.
|
||||
|
||||
The package stage writes a stage manifest containing only final published records and
|
||||
their integrity. Diagnostic checks distinguish missing package, invalid package, and
|
||||
stale package. Rollback is to the prior `package/` directory; legacy preview outputs
|
||||
remain untouched throughout rollout.
|
||||
|
||||
## Deferred Decisions
|
||||
|
||||
- Rich per-feature semantics, routing graph delivery and simulation inputs are not in
|
||||
v1; they should be introduced through a later schema version only when a downstream
|
||||
consumer requires them.
|
||||
- Asset coverage improvements are downstream of this contract work.
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Stage ordering, manifests, external-command boundary, and compression contracts."}
|
||||
{"file":".trellis/spec/config/index.md","reason":"Area output-path normalization and configuration contract."}
|
||||
{"file":".trellis/spec/preview/index.md","reason":"Preview config injection and browser runtime compatibility."}
|
||||
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Blender-to-Node-to-browser coordinate and artifact contract risks."}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Asset Package Contract Implementation Plan
|
||||
|
||||
## Phase 0: Contract Lock-In
|
||||
|
||||
- [ ] Add a package-contract module that owns v1 schema constants, allowed roles and
|
||||
categories, URI containment validation, manifest construction and validation.
|
||||
- [ ] Add focused unit fixtures for valid manifests and each invalid condition:
|
||||
missing schema/placement/bounds, malformed coordinates, invalid role/category,
|
||||
absolute or traversing URI, duplicate asset ID, missing primary scene, and referenced
|
||||
file outside the package.
|
||||
- [ ] Document `package/` as the sole downstream boundary in README and pipeline/config
|
||||
specs; retain a clear distinction between release assets and preview artifacts.
|
||||
|
||||
Gate: manifest v1 can be created and structurally validated without invoking Blender,
|
||||
Cesium or QGIS.
|
||||
|
||||
## Phase 1: Package Paths and Static Export
|
||||
|
||||
- [ ] Extend `scripts/lib/area-config.js` with paths for `packageDir`, package manifest,
|
||||
package model directory, primary GLB and semantic layer GLBs; keep all path derivation
|
||||
in this module.
|
||||
- [ ] Change `blender/export_cesium.py` metadata output to a static manifest seed:
|
||||
bounds, ENU placement and static scene/layer declarations only. Remove local source
|
||||
paths and inline Cesium source code from the published data.
|
||||
- [ ] Direct static GLB output to package staging paths and retain dynamic signal/countdown
|
||||
output as preview-only paths outside package staging.
|
||||
- [ ] Update `build-area.js`, semantic asset checks, stage manifests and diagnostics to
|
||||
consume the new path contract rather than constructing paths locally.
|
||||
|
||||
Gate: a Cesium export produces a valid static manifest seed and every declared static
|
||||
asset exists under package staging; no preview-only asset appears in it.
|
||||
|
||||
## Phase 2: Compression and Atomic Publication
|
||||
|
||||
- [ ] Refactor the default compression stage to operate on staged package files,
|
||||
preserving the final manifest shape while replacing only the primary GLB bytes and
|
||||
integrity data.
|
||||
- [ ] Add a `package` stage after `compress`; validate every asset and atomically promote
|
||||
staging to `outputs/<area-id>/package/`.
|
||||
- [ ] Update canonical stage ordering, `all`, interactive CLI labels and defaults.
|
||||
- [ ] Ensure a failed compression/package validation never replaces an existing published
|
||||
package; record diagnostics sufficient to identify the failed source/staging path.
|
||||
|
||||
Gate: a full build creates one publishable package with compressed primary GLB and no
|
||||
duplicate static GLBs at the area root.
|
||||
|
||||
## Phase 3: Preview Adapter and Compatibility
|
||||
|
||||
- [ ] Change preview HTML generation to reference `package/manifest.json` relative to
|
||||
the area output root.
|
||||
- [ ] Move route/vehicle/dynamic-signal discovery into a preview-only descriptor beneath
|
||||
`_preview/`, and make `cesium-preview.js` merge it after loading the package manifest.
|
||||
- [ ] Preserve existing preview controls and graceful behavior when preview-only data is
|
||||
absent; preview must still render the package static scene.
|
||||
- [ ] Update `diagnose:area`, `check:area` and stage manifest freshness to distinguish
|
||||
package artifacts from preview-only files.
|
||||
|
||||
Gate: the local Cesium preview loads the published package and continues to show
|
||||
optional cruise/dynamic preview content without adding either to package manifest.
|
||||
|
||||
## Phase 4: Downstream Consumption Proof
|
||||
|
||||
- [ ] Add Cesium example code that resolves the manifest URL, validates placement and
|
||||
loads the full scene or selected layers.
|
||||
- [ ] Add Three.js example code with a manifest-relative resolver and an explicit ENU
|
||||
placement handoff to the host's georeferencing integration.
|
||||
- [ ] Add automated resolver tests so neither example can regress to page-relative,
|
||||
desktop-absolute or repository-relative URLs.
|
||||
- [ ] Publish consumer documentation including overlap rules for complete scene vs layers.
|
||||
|
||||
Gate: examples run from a copied `package/` directory and require no files outside it.
|
||||
|
||||
## Phase 5: Full Verification and Migration
|
||||
|
||||
- [ ] Run focused Node tests for package schema, stage resolution, compression metadata,
|
||||
preview behavior, diagnostics and asset budgets.
|
||||
- [ ] Run the full default build for the primary area, then `check:area` and package
|
||||
integrity validation.
|
||||
- [ ] Inspect the packaged GLB structure and validate the Cesium example in browser;
|
||||
verify Three.js resolver output with the same manifest.
|
||||
- [ ] Capture expected intentional output-contract changes in docs/changelog and update
|
||||
relevant Trellis pipeline/config/preview specs.
|
||||
- [ ] Preserve legacy root-level artifacts during rollout; document that downstream
|
||||
consumers must migrate to `package/manifest.json` rather than treating legacy files
|
||||
as fallback.
|
||||
|
||||
## High-Risk Boundaries
|
||||
|
||||
- `blender/export_cesium.py` to Node: static metadata fields and semantic asset file
|
||||
names are a cross-runtime contract.
|
||||
- Node packaging to preview runtime: preview-only dynamic data must never silently enter
|
||||
the package manifest.
|
||||
- Compression to publication: paths must remain package-relative and a partial write must
|
||||
not replace the last valid package.
|
||||
- Consumers: ENU axes and heading correction are load-bearing; examples must not create
|
||||
a second, incompatible georeferencing convention.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
npm run test:build-stages
|
||||
npm run test:compress-glb
|
||||
npm run test:preview-assets
|
||||
npm run test:budgets
|
||||
npm run test:preflight
|
||||
npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
node scripts/glb-digest.js outputs/nantaizi-lake-innovation-valley/package/models/nantaizi-lake-innovation-valley.glb
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# Reusable asset package contract
|
||||
|
||||
## Goal
|
||||
|
||||
将当前面向 Cesium 预览的区域输出升级为可由多个下游项目稳定消费的静态资产包。资产包
|
||||
必须有版本化 manifest、仅使用包内相对引用,并明确发布资产与本项目调试/预览产物的边界。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- `blender/export_cesium.py` 已生成 `<area>.json`,包含主 GLB、roads/buildings/
|
||||
vegetation/water 分层 GLB、WGS84 anchor、ENU 坐标约定、heading correction 和 OSM bounds。
|
||||
- 该 JSON 同时包含桌面绝对 `source_osm` / `source_geojson` 路径、Cesium 代码片段,以及
|
||||
动态信号灯和倒计时预览资产;因此不是可发布的下游契约。
|
||||
- `outputs/<area>/` 还包含 QGIS、GeoJSON、Blender、Cesium HTML/runtime、车辆巡航和自检文件;
|
||||
它是构建工作目录,不是干净的发布目录。
|
||||
- 当前默认 Cesium GLB 已在构建末端压缩,且 metadata 中的资产 URL 使用同目录相对文件名。
|
||||
- 项目目标是生成可复用资产;交通仿真、跟车和信号调度属于下游运行时能力,不属于资产包首版。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:定义一个版本化 `manifest.json` v1,描述区域 ID、WGS84 anchor、ENU 轴向与 heading
|
||||
correction、WGS84 bounds、发布资产、语义类别和包内相对路径。
|
||||
- R2:资产包只含可复用的静态交付资产;主场景和道路、建筑、植被、水体等分层模型必须有
|
||||
明确的角色、加载语义和默认行为。
|
||||
- R3:manifest 和包内文件不得包含桌面绝对路径、构建临时目录、Cesium HTML/runtime、
|
||||
QGIS、GeoJSON、`.blend`、车辆巡航或动态交通信号调度依赖。
|
||||
- R4:在不破坏现有区域预览、诊断和构建中间产物的前提下,增加明确的资产包发布阶段。
|
||||
- R5:提供 Cesium 与 Three.js 的最小加载示例,均从 manifest 读取包内相对 URL,并按相同的
|
||||
WGS84/ENU 契约放置主场景或分层资产。
|
||||
- R6:定义发布资产、可选静态资产和本项目仅检查产物的分类规则,并由测试验证。
|
||||
- R7:完整构建后的资产包应可独立复制到其他项目,而无需本仓库的 `outputs` 目录结构或本地路径。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 目标区域生成一个可独立分发的资产包目录,其中仅有 manifest 和 manifest 引用的发布资产。
|
||||
- [ ] manifest 的 schema version、坐标契约、bounds、所有资产类别和 URL 可由程序校验。
|
||||
- [ ] 所有 manifest URL 都是安全的包内相对路径;不得含绝对路径、`..` 或未声明文件。
|
||||
- [ ] 主 GLB 与每个发布分层资产都能在 Cesium 和 Three.js 示例中按 manifest 正确放置与加载。
|
||||
- [ ] 车辆、巡航路线、Cesium preview HTML/runtime、QGIS/GeoJSON/`.blend` 和动态信号调度资产
|
||||
不会进入发布包。
|
||||
- [ ] 既有 `outputs/<area>/` 预览工作流继续可用,现有非交互 build 命令保持兼容。
|
||||
- [ ] 发布包缺文件、manifest 路径越界、坐标字段无效或资产类别不合法时,构建/校验非零退出。
|
||||
|
||||
## Proposed Delivery Phases
|
||||
|
||||
1. 契约与目录边界:冻结 manifest v1 schema、发布目录结构、资产角色和坐标定义。
|
||||
2. 资产包发布阶段:从现有 Cesium 导出与压缩结果收集、校验并写入独立包目录。
|
||||
3. 下游消费证明:Cesium / Three.js 示例仅依赖 manifest 与包内容,并覆盖主场景和按类别加载。
|
||||
4. 质量门与迁移:增加结构、路径隔离、坐标和加载验证;保留旧预览输出并记录迁移规则。
|
||||
|
||||
## Key Decision
|
||||
|
||||
- 发布包根目录固定为 `outputs/<area-id>/package/`。该目录是可整体复制给下游项目的唯一
|
||||
发布边界;GLB 和 manifest 直接生成或移动到这里,避免与工作目录再保留一套大模型副本。
|
||||
本项目的预览页可通过包内相对路径读取发布资产,但 HTML/runtime 本身不属于发布包。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 交通流、车辆行为、信号相位控制、路口调度和其他运行时仿真。
|
||||
- 在本任务中扩展 OSM 到建筑、路灯、标志、植被等资产覆盖率或生成质量。
|
||||
- 删除既有 `outputs/<area>/` 中由用户保留的历史调试产物。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "asset-package-contract",
|
||||
"name": "asset-package-contract",
|
||||
"title": "Reusable asset package contract",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-11",
|
||||
"completedAt": "2026-08-12",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Interactive Area Build CLI Design
|
||||
|
||||
## Scope
|
||||
|
||||
Add a zero-dependency terminal interface for choosing an area configuration and one or more existing
|
||||
pipeline stages. It delegates execution to the existing `build-area.js` entry point and does not change
|
||||
the pipeline's stage implementations or generated artifacts.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
npm run build
|
||||
-> scripts/interactive-build.js
|
||||
-> terminal menus (TTY only)
|
||||
-> node scripts/build-area.js --config <area> --stages <canonical-list>
|
||||
-> existing build pipeline
|
||||
```
|
||||
|
||||
Extract the stage metadata, aliases, canonical execution order, and mutual-exclusion validation from
|
||||
`build-area.js` into a CommonJS module under `scripts/lib/`. Both the interactive script and
|
||||
`build-area.js` consume this module, making the menu's options the same source of truth as execution.
|
||||
|
||||
## Interaction
|
||||
|
||||
1. Discover and sort `config/areas/*.json`; present a single-select area menu.
|
||||
2. Present a multi-select stage menu with descriptions and selection markers.
|
||||
3. Arrow keys move focus, space toggles, enter confirms, and Ctrl-C/Escape cancels.
|
||||
4. The menu prevents or reports the `intermediates`/`reimport` conflict before spawning a build.
|
||||
5. The selected stages are normalized to canonical pipeline order and passed to `build-area.js`.
|
||||
|
||||
The interactive entry requires both stdin and stdout to be TTYs. Otherwise it exits non-zero with an
|
||||
instruction to use `npm run build:area -- --config ... --stages ...`; it never falls back to defaults.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- `build-area.js` remains the execution owner and keeps its CLI flags and defaults.
|
||||
- `npm run build:area` continues to invoke it directly.
|
||||
- `npm run build` changes only from the former default build alias to the interactive wrapper.
|
||||
- A successful `cesium` selection continues to generate preview output through the existing behavior;
|
||||
selecting `preview` explicitly remains valid for a standalone preview refresh.
|
||||
|
||||
## Validation
|
||||
|
||||
- Unit-test stage normalization, alias expansion, ordering, and mutual exclusion.
|
||||
- Unit-test config discovery and non-TTY refusal without launching a build.
|
||||
- Syntax-check both CLI entry scripts.
|
||||
- Manually exercise the menu in a TTY: select an area, select unordered stages, confirm the canonical
|
||||
command preview, and cancel before execution.
|
||||
|
||||
## Risks And Rollback
|
||||
|
||||
Raw terminal input must always restore raw mode and cursor state on confirmation, cancellation, and
|
||||
errors. The wrapper only spawns the unchanged existing build script, so rollback is deleting the new
|
||||
interactive entry and restoring the `build` package script.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Interactive Area Build CLI Implementation Plan
|
||||
|
||||
1. Extract stage definitions, aliases, canonical ordering, and conflict validation from
|
||||
`scripts/build-area.js` into a shared CommonJS module.
|
||||
2. Update `build-area.js` to consume the shared resolver without changing non-interactive semantics.
|
||||
3. Add `scripts/interactive-build.js` with TTY detection, area discovery, raw-mode selection menus,
|
||||
cancellation handling, and child-process delegation to `build-area.js`.
|
||||
4. Change `package.json` so `npm run build` calls the new interactive script while `build:area` remains
|
||||
unchanged.
|
||||
5. Add focused Node tests for stage planning and non-TTY behavior; do not invoke Blender, QGIS, or a
|
||||
real area build in tests.
|
||||
6. Run syntax checks, the focused tests, existing preflight/preview tests, and manually exercise the
|
||||
interactive flow in a TTY.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Interactive area build CLI
|
||||
|
||||
## Goal
|
||||
|
||||
提供一个交互式区域构建入口,减少手写 `npm run build:area -- --config ... --stages ...`
|
||||
的频率;用户可选择区域和一个或多个构建阶段,CLI 按既有阶段约束执行。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- 当前 `scripts/build-area.js` 支持 `intermediates`、`reimport`、`blender`、`cesium`、
|
||||
`preview` 与 `compress` 六个阶段,并接受逗号分隔的 `--stages`。
|
||||
- `intermediates` 与 `reimport` 互斥:前者会从 OSM 重建 GeoPackage,后者回导 QGIS 编辑。
|
||||
- 执行顺序固定为 `intermediates/reimport -> blender -> cesium -> preview -> compress`;
|
||||
`cesium` 当前会自动写入 preview 产物。
|
||||
- 项目没有终端交互依赖;现有命令行调用必须继续可用于脚本和自动化。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:提供交互式入口,列出可用区域配置并允许用户选择目标区域。
|
||||
- R2:交互式入口允许一次选择多个阶段,并清晰展示阶段名称与用途。
|
||||
- R3:选择互斥阶段时必须阻止执行并解释原因;不得静默选择其中之一。
|
||||
- R4:执行多个阶段时必须使用既有依赖顺序,而非用户勾选顺序。
|
||||
- R5:保留 `npm run build:area -- --config ... --stages ...` 的现有非交互行为与语义。
|
||||
- R6:无 TTY、取消输入或无效输入时必须安全退出,不启动任何构建。
|
||||
- R7:TTY 交互使用方向键移动、空格多选、回车确认;菜单应显示当前选择状态。
|
||||
- R8:`npm run build` 启动交互式入口;`npm run build:area` 继续保留为非交互入口。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 用户可从终端选择区域和多项阶段,无需手写配置路径或逗号分隔阶段名。
|
||||
- [ ] 选择 `intermediates` 与 `reimport` 时,CLI 明确报互斥错误且不运行构建。
|
||||
- [ ] 选择乱序的多个阶段时,实际执行顺序仍遵循既有管线顺序。
|
||||
- [ ] 既有非交互 `build:area` 调用保持兼容。
|
||||
- [ ] 取消或在非交互环境调用不会触发构建。
|
||||
- [ ] 用户可用方向键、空格和回车完成区域及阶段选择。
|
||||
- [ ] `npm run build` 启动交互菜单,`npm run build:area` 的现有调用保持不变。
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- 采用终端原生多选菜单,而非输入逗号分隔编号;避免引入运行时依赖。
|
||||
- `npm run build` 作为日常交互入口;`npm run build:area` 继续服务自动化和精确调用。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 修改各 stage 的构建业务逻辑、依赖关系或产物格式。
|
||||
- 将巡航路线、QGIS 图层编辑或区域配置编辑纳入本次交互流程。
|
||||
- GUI 或浏览器界面的构建控制台。
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||
- Lightweight tasks can remain PRD-only.
|
||||
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "interactive-area-cli",
|
||||
"name": "interactive-area-cli",
|
||||
"title": "Interactive area build CLI",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-11",
|
||||
"completedAt": "2026-08-11",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Design: Vehicle Incident And Information Preview
|
||||
|
||||
## Boundary
|
||||
|
||||
This is browser-only Cesium preview state. It changes neither generated OSM data nor `package/` assets.
|
||||
Vehicle routes and models remain `_preview/` resources; refresh resets all vehicle event state.
|
||||
|
||||
## Data Model
|
||||
|
||||
Each object returned by `addCruiseVehicle()` gains:
|
||||
|
||||
```js
|
||||
{
|
||||
id: "vehicle-1",
|
||||
modelName: "car_a01_002",
|
||||
status: "normal" | "breakdown" | "accident",
|
||||
incidentNote: "",
|
||||
motion: { state },
|
||||
markerEntity
|
||||
}
|
||||
```
|
||||
|
||||
The current route distance remains the single motion source. The clock tick leaves it unchanged while
|
||||
status is not `normal`; returning to normal resumes from that position.
|
||||
|
||||
## Interaction Flow
|
||||
|
||||
1. Vehicle Cesium Entities carry a `vehicleId` property.
|
||||
2. A `ScreenSpaceEventHandler` picks a clicked entity, resolves its vehicle, synchronizes the existing
|
||||
vehicle selector, and opens a positioned information card.
|
||||
3. The card displays vehicle number, model family, route label/length, configured speed, state, and note.
|
||||
4. Card controls set normal/breakdown/accident and persist only in the in-memory vehicle object.
|
||||
5. State changes update marker visibility, the route material, card content, and Cesium render request.
|
||||
|
||||
## Visual Contract
|
||||
|
||||
- Normal: no marker; original route color; motion active.
|
||||
- Breakdown: yellow wrench label above the vehicle; route retains its original color; motion frozen.
|
||||
- Accident: red warning-triangle label above the vehicle; route material becomes red; motion frozen.
|
||||
- Marker positions use the same vehicle `CallbackProperty`, so they move with the vehicle before an event
|
||||
and remain at the incident location while stopped.
|
||||
|
||||
## Compatibility And Risks
|
||||
|
||||
- Picking must ignore routes, static models, and signal entities.
|
||||
- No route must retain the existing disabled cruise controls and never show a card.
|
||||
- The card must be HTML/CSS UI, not Cesium InfoBox, because the viewer deliberately disables InfoBox.
|
||||
- The feature must not alter signal runtime behavior or vehicle asset URLs.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add an initially hidden vehicle information card to `area-preview.js` and responsive card styles to
|
||||
`cesium-preview.css`.
|
||||
2. Extend the browser runtime vehicle records with stable identity, parsed model metadata, incident status,
|
||||
note, and a marker entity.
|
||||
3. Gate route-distance advancement on normal state; add status transition helper for marker, route material,
|
||||
and card refresh.
|
||||
4. Add Cesium click picking and card controls. Keep the existing selector synchronized for follow/route use.
|
||||
5. Add focused assertions to `test-preview-assets.js` for card wiring, status behavior, and marker/route
|
||||
contracts.
|
||||
6. Run syntax checks, preview test, stage test, target-area preview regeneration, and `check:area`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert only the preview HTML/CSS/runtime/test files. No package, Blender, route, or OSM product files should
|
||||
need rollback.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Vehicle incident and information preview
|
||||
|
||||
## Goal
|
||||
|
||||
在 Cesium 验证预览中,让使用者能够查看每辆巡航车辆的基础信息,并人为标记车辆事故/故障状态,直观看到该状态对车辆和路线展示的影响。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- 车辆是 `scripts/lib/cesium-preview.js` 创建的 Cesium Entity;当前已有车型、路线、巡航速度、
|
||||
跟随、显隐与路线选择。
|
||||
- 车辆模型和路线都属于 `_preview/` 验证资源,不是 area package 的下游资产契约。
|
||||
- 当前不存在车辆业务资料、事故状态、碰撞检测或自动事件推演。
|
||||
- 项目定位是资产生成与验证,不能将交通流/事故仿真作为本任务的范围。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 每辆预览车辆必须有稳定的显示编号、车型、当前路线、设定速度和行驶状态。
|
||||
- 点击场景中的车辆后,必须弹出该车辆的信息卡,展示基础信息与当前事件状态;信息卡内完成状态与说明编辑。
|
||||
- 使用者必须能将选中车辆在正常、故障和事故状态之间切换,并能提供简短事件说明。
|
||||
- 故障和事故是不同状态:二者均停止巡航;故障使用黄色标记,事故使用红色标记并提示路线中断。
|
||||
- 故障使用车辆顶部黄色扳手标记;事故使用红色警示三角标记,并将该车辆当前路线改为红色。
|
||||
- 非正常车辆必须有可见的场景标记;恢复正常后继续按既有路线巡航。
|
||||
- 事故/故障仅在当前浏览器预览会话中存在;刷新或重建预览后不保留。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 自动碰撞检测、车辆间物理碰撞、自动事故生成。
|
||||
- 信号配时、交通流、调度策略或持久化事件记录。
|
||||
- 向 `package/` 加入车辆、路线或事故数据。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 用户可点击任一可见车辆,弹出其基础信息、状态和事件说明;现有车辆选择控件仍可用于路线和跟随。
|
||||
- [ ] 用户可标记故障或事故,并看到车辆停止、状态文本和明显的场景标记。
|
||||
- [ ] 故障显示黄色扳手;事故显示红色警示三角且当前路线变红。
|
||||
- [ ] 用户可恢复正常,车辆继续其现有路线;其他车辆不受影响。
|
||||
- [ ] 无可用路线时,车辆信息和事故控件按既有禁用语义处理。
|
||||
- [ ] 预览资产、路线与现有信号灯 runtime 继续可加载;不新增下游 package 内容。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "vehicle-incident-info",
|
||||
"name": "vehicle-incident-info",
|
||||
"title": "Vehicle incident and information preview",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-12",
|
||||
"completedAt": "2026-08-12",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Native Lane Markings Design
|
||||
|
||||
## Architecture
|
||||
|
||||
The native compiler remains the source of truth. It derives two new polygon
|
||||
layers alongside its existing surface, sidewalk, lane-centerline, and
|
||||
connector outputs:
|
||||
|
||||
```text
|
||||
canonical directed roads + lane centerlines + junction cutbacks
|
||||
|
|
||||
+-- lane separators: paint polygons between adjacent same-direction lanes
|
||||
|
|
||||
+-- direction arrows: repeated through-arrow template on directed lanes,
|
||||
| outside the reserved junction marking zone
|
||||
|
|
||||
+-- turn arrows: tested existing template, anchored to an incoming lane
|
||||
only when that lane has an explicit supported turn:lanes value
|
||||
```
|
||||
|
||||
No OSM2streets rendered geometry is consumed by this path. The existing arrow
|
||||
template library is reused only as a geometry/style asset, so the native lane
|
||||
ID and OSM tags remain the evidence for placement.
|
||||
|
||||
## Contracts
|
||||
|
||||
- `layers/lane_separators.geojson`: polygon FeatureCollection. Each feature
|
||||
records `native_id`, directed `road_id`, adjacent lane indices, source OSM
|
||||
ways, and `native-road-lane-separator/v1` provenance.
|
||||
- `layers/turn_arrows.geojson`: polygon FeatureCollection. Each feature records
|
||||
its `native_id`, `road_id`, `lane_id`, OSM way IDs, direction, lane index,
|
||||
maneuver, template asset, placement distance, and placement provenance.
|
||||
- Unsupported turn values, a missing usable incoming-lane segment, or an
|
||||
insufficient pre-junction placement distance create a diagnostic and no
|
||||
arrow geometry.
|
||||
- `layers/direction_arrows.geojson`: polygon FeatureCollection. Each feature
|
||||
records the native lane and directed road, OSM way IDs, a stable sequence
|
||||
index, its distance along the lane, and `native-road-direction-arrow/v1`
|
||||
provenance. It uses the tested `through` template but is not a turn claim.
|
||||
- The workbench serves both layers, draws them separately from its current
|
||||
centerline/connector debug layer, and selects them by `native_id`.
|
||||
- `catalog.NATIVE_ROAD_LAYERS` maps the two native sources to the existing
|
||||
`lane_separators` and `lane_arrows_webscale` Blender materials. The native
|
||||
adapter does not extend the osm2streets scene-layer registry.
|
||||
|
||||
## Placement
|
||||
|
||||
An incoming lane is oriented in driving direction. A turn arrow is sampled
|
||||
from that lane's endpoint backwards by the configured safe distance, staying
|
||||
outside the junction cutback. Its template basis uses the sampled lane tangent;
|
||||
therefore it is on and aligned with the lane rather than the OSM centerline or
|
||||
a screen-space direction. Multiple template rings remain separate polygons.
|
||||
|
||||
Lane separators are narrow polygons centered between adjacent lane centerlines
|
||||
on a single directional carriageway. They stop at the same junction cutbacks as
|
||||
the lane centerlines. A one-lane direction produces none.
|
||||
|
||||
Direction arrows are sampled at a fixed road-scale interval along the same
|
||||
directed lane centerline. Their candidates exclude both endpoint buffers and
|
||||
the turn-arrow reserve at the incoming end. This preserves a readable repeated
|
||||
direction cue without overlapping a turn instruction at a junction.
|
||||
|
||||
## Compatibility And Rollback
|
||||
|
||||
All new files are additive under `native-road/layers/`. The existing
|
||||
osm2streets/QGIS layer contract and `package/` are unchanged. Selecting
|
||||
`--road-provider osm2streets` remains rollback. Missing native marking files
|
||||
are a native Blender build error rather than a silent omission.
|
||||
|
||||
## Risks
|
||||
|
||||
- OSM turn tags can be incomplete or incompatible with the inferred lane
|
||||
count. These are diagnostics, not guessed arrows.
|
||||
- Very short approaches can have no safe position before the cutback. They are
|
||||
skipped with a source-traceable diagnostic.
|
||||
- Blender and Cesium need a real native build to verify the mesh/material
|
||||
contract, not only GeoJSON unit tests.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add reusable template-placement helper to the existing turn-arrow module,
|
||||
preserving its supported-asset gate and output ring shape.
|
||||
2. Extend the native compiler with lane-separator, repeated road-direction
|
||||
arrow, and explicit-turn-arrow polygon generation plus source diagnostics
|
||||
and layer persistence.
|
||||
3. Extend native output records/counts and Blender adapter mappings. Reuse
|
||||
existing `lane_separators` and `lane_arrows_webscale` materials only.
|
||||
4. Add Workbench layers, toggles, selection/provenance inspector entries, and
|
||||
preserve the existing Workbench-only direction triangle behavior.
|
||||
5. Add focused fixtures for supported turn placement, unsupported maneuver,
|
||||
short approach skip, and separator geometry; run native/workbench/build
|
||||
stage tests.
|
||||
6. Build Nantaizi with `blender,cesium,preview --road-provider native`, inspect
|
||||
the final preview, and confirm no `package/` publication occurred.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run test:native-road
|
||||
npm run test:road-workbench
|
||||
npm run test:turn-lane-arrows
|
||||
npm run test:build-stages
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json \
|
||||
--stages blender,cesium,preview --road-provider native
|
||||
```
|
||||
|
||||
Rollback is selecting `--road-provider osm2streets`; no existing output path
|
||||
is replaced.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Native road lane markings and turn arrows
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the native-road visual language for Nantaizi before any cross-area
|
||||
migration: lane separators, travel-direction markers, and OSM-backed turn
|
||||
arrows must be inspectable in the Road Workbench and visible in Blender and
|
||||
Cesium output.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Native output currently contains directed lane centerlines and connector
|
||||
curves, but it does not emit paintable lane-separator or turn-arrow polygons.
|
||||
- The existing osm2streets path has tested arrow templates in
|
||||
`scripts/lib/turn-lane-arrows.js`, the `lane_arrows_webscale` material layer,
|
||||
and a matching Blender material. Reuse these instead of introducing a second
|
||||
arrow style.
|
||||
- Earlier reviews established that direction markers must sit on the OSM / lane
|
||||
centerline, use a clearly directional sharp triangle, and never be treated as
|
||||
a road-surface decoration that drifts sideways.
|
||||
- Turn arrows must follow the actual incoming lane and be placed before its
|
||||
junction, with OSM source way, direction, lane index, and maneuver retained
|
||||
as provenance.
|
||||
- Scope remains Nantaizi only. Existing osm2streets output remains untouched.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Native compilation emits polygonal lane-separator markings derived from
|
||||
its own directed lane geometry.
|
||||
- R2: Native compilation emits turn-arrow polygons for supported OSM
|
||||
`turn:lanes` maneuvers, using the existing tested arrow templates and the
|
||||
exact native incoming-lane centerline for placement.
|
||||
- R2a: Native compilation emits repeated straight-ahead direction-arrow
|
||||
polygons along directed lanes, matching the visual role of osm2streets'
|
||||
ordinary road arrows. These are a separate layer from turn arrows, retain
|
||||
their own provenance, and leave a clear buffer around junction turn arrows.
|
||||
- R3: Native output preserves provenance for every marking: native road/lane
|
||||
ID, OSM way IDs, direction, lane number, maneuver, and placement method.
|
||||
- R4: The Workbench renders markings in a separately controllable layer and
|
||||
exposes those provenance fields on selection.
|
||||
- R5: The native Blender adapter consumes native marking layers through the
|
||||
existing lane-separator and lane-arrow material layers; Cesium must receive
|
||||
the same geometry through the exported GLB.
|
||||
- R6: Unsupported, unplaceable, or ambiguous arrow inputs become diagnostics;
|
||||
the compiler must not invent a maneuver.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Nantaizi native output contains valid polygon GeoJSON for generated lane
|
||||
separators, repeated road direction arrows, and every supported, explicitly
|
||||
tagged turn arrow.
|
||||
- [ ] A Workbench user can toggle, select, and inspect a generated marker and
|
||||
see its lane, OSM, maneuver, and placement provenance.
|
||||
- [ ] A selected direction marker is geometrically aligned to its directed lane
|
||||
centerline; a selected turn arrow is on its incoming lane before the junction.
|
||||
- [ ] Blender scene output and Cesium GLB contain native lane markings and
|
||||
arrows with the existing visual material language.
|
||||
- [ ] Unit tests cover a normal supported arrow, an unsupported maneuver, and
|
||||
an unsafe/too-short placement; native compile and final Nantaizi visual build
|
||||
pass without publishing `package/`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Inventing turn arrows for untagged lanes, traffic-control semantics, changing
|
||||
QGIS/osm2streets layers, or processing another area.
|
||||
|
||||
## Key Decision
|
||||
|
||||
- Sharp travel-direction triangles remain a Workbench-only inspection aid.
|
||||
They explain raw OSM node order after a road is selected.
|
||||
- Repeated `through` direction arrows are final road markings, distinct from
|
||||
both those debug triangles and OSM-backed junction turn arrows. They are
|
||||
placed on native lane centerlines at a fixed interval and enter the same
|
||||
Blender/Cesium material layer as turn arrows.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-road-lane-markings",
|
||||
"name": "native-road-lane-markings",
|
||||
"title": "Native road lane markings and turn arrows",
|
||||
"description": "Complete Nantaizi native lane separators, travel direction and turn-arrow geometry through Road Workbench, Blender and Cesium before cross-area migration.",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-14",
|
||||
"completedAt": "2026-08-14",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-13-native-road-compiler",
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 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` |
|
||||
|
||||
@@ -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**
|
||||
|
||||
65
README.md
65
README.md
@@ -4,15 +4,19 @@
|
||||
|
||||
## 目标产物
|
||||
|
||||
每个区域默认输出到 `outputs/<area-id>/`:
|
||||
每个区域默认输出到 `outputs/<area-id>/`。其中只有 `package/` 是可复制给下游的发布边界:
|
||||
|
||||
- `package/manifest.json`:版本化 `osm-asset-package/v1` manifest,声明 WGS84 anchor、ENU(X east/Y north/Z up,米)、heading correction 和包内模型 URL
|
||||
- `package/models/<area-id>.glb`:完整静态场景;`roads.glb`、`buildings.glb`、`vegetation.glb`、`water.glb` 是可选重叠分层,默认不要与完整场景同时加载
|
||||
|
||||
其余内容仍是本项目工作目录:
|
||||
|
||||
- `<area-id>.blend`:Blender 场景,包含道路、建筑、水体、植被等
|
||||
- `<area-id>.png`:Blender 预览渲染
|
||||
- `<area-id>.glb`:Cesium 可加载的完整 3D 模型(基线产物)
|
||||
- `<area-id>.glb`:Cesium 可加载的完整 3D 模型(默认压缩交付产物)
|
||||
- `<area-id>-roads.glb`、`-buildings.glb`、`-vegetation.glb`、`-water.glb`:Cesium 分类检查用的辅助模型
|
||||
- `<area-id>.json`:Cesium 放置元数据和示例代码
|
||||
- `<area-id>-cesium-preview.html`:Cesium 本地预览页
|
||||
- `<area-id>-compressed-webp768.glb/json/html`:显式 `compress` 阶段生成的可选压缩预览产物
|
||||
- `osm2streets_web_out/`:osm2streets GeoJSON 中间层
|
||||
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`:QGIS 调试资产
|
||||
|
||||
@@ -37,12 +41,14 @@ npm install
|
||||
|
||||
## 主流程
|
||||
|
||||
默认构建南台子湖创新谷样例:
|
||||
交互式选择区域和构建阶段:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
使用方向键移动、空格多选阶段、回车开始构建;`intermediates` 与 `reimport` 不能同时选择。
|
||||
|
||||
指定区域配置:
|
||||
|
||||
```bash
|
||||
@@ -60,15 +66,14 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport
|
||||
```
|
||||
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出 `.glb`/`.json`,并生成 Cesium 预览 HTML。`preview` 只在已有 `.glb/.json` 时补生成 HTML。`compress` 从已有 `.glb/.json/html` 生成并列压缩产物,不覆盖默认 GLB。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出包 staging,`compress` 压缩 staging 主 GLB,`package` 校验并原子发布 `package/`,`preview` 生成仅用于本地验证的 HTML 和 `_preview/` 动态描述符。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
|
||||
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB;该模式用于单独检查生成结果,主 GLB 仍是完整场景和下游使用的基线。
|
||||
压缩预览同样保留这些分类检查资产;重跑 `compress` 后应打开新生成的压缩预览 HTML。
|
||||
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB;该模式用于单独检查生成结果,主 GLB 是压缩后的下游交付资产。
|
||||
|
||||
`compress` 不含在 `all` 里,也不能从配置文件默认开启。需要重导出 Cesium 后立刻生成压缩产物时,显式跑:
|
||||
`compress` 包含在完整构建的默认阶段和 `all` 中。需要对已有标准产物重新压缩时,可以显式跑:
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress
|
||||
```
|
||||
|
||||
## 区域诊断
|
||||
@@ -91,6 +96,37 @@ npm run preflight:area -- --config config/areas/nantaizi-lake-innovation-valley.
|
||||
预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次
|
||||
验证的 config / OSM 文件摘要和检查结果。
|
||||
|
||||
## Native Road Workbench
|
||||
|
||||
`road:compile` 是独立于 osm2streets 的实验性道路编译器入口。它从 OSM 生成可追溯的
|
||||
道路模型、基础道路面、诊断与对比摘要,写入 `outputs/<area-id>/native-road/`,不会覆盖
|
||||
`osm2streets_web_out/` 或影响现有 Blender/Cesium 构建:
|
||||
|
||||
```bash
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
```
|
||||
|
||||
`road:check` 验证已发布 connector 与语义行驶动作的一致性,并在诊断包含 error、图层缺失或
|
||||
动作/几何对应关系不一致时以非零退出。warning 保留给工作台审查,不会阻止产物打开。
|
||||
|
||||
启动本地浏览器工作台:
|
||||
|
||||
```bash
|
||||
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
```
|
||||
|
||||
工作台默认先编译,并在 `http://127.0.0.1:8787/` 启动基于 OpenLayers 的 GIS 工作台,展示
|
||||
OSM 道路、原生结果、车道、行驶动作、诊断和每个值的来源。可编辑道路宽度、车道数、两侧
|
||||
人行道,以及候选路口端点连接。保存写入
|
||||
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
|
||||
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
|
||||
|
||||
道路按行驶方向显示:点击道路面、中心线、车道或转向路径后,蓝色三角形标记当前方向在 OSM
|
||||
原始中心线上的方向;右侧“路口连接”只列出该方向到达终点路口后可驶入的目标道路,并标记为
|
||||
左转、直行、右转或掉头。车道按当前方向从左向右编号。内部断头诊断中的近邻候选不会自动连接,
|
||||
必须由用户显式暂存、保存并重新编译。
|
||||
|
||||
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
|
||||
现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。
|
||||
缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`。
|
||||
@@ -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`)做两件事:
|
||||
|
||||
BIN
assets/preview/vehicle-accident.png
Normal file
BIN
assets/preview/vehicle-accident.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/preview/vehicle-breakdown.png
Normal file
BIN
assets/preview/vehicle-breakdown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -579,7 +579,8 @@ def export_glb(filepath, meshes):
|
||||
|
||||
def semantic_asset_specs(glb_path, meshes):
|
||||
"""Derive optional inspection assets from the scene's named collections."""
|
||||
stem, extension = os.path.splitext(glb_path)
|
||||
model_dir, filename = os.path.split(glb_path)
|
||||
_, extension = os.path.splitext(filename)
|
||||
by_collection = {}
|
||||
for collection in bpy.context.scene.collection.children:
|
||||
by_collection[collection.name] = set(collection.all_objects)
|
||||
@@ -592,7 +593,7 @@ def semantic_asset_specs(glb_path, meshes):
|
||||
subset = [obj for obj in meshes if obj in objects]
|
||||
if not subset:
|
||||
continue
|
||||
path = stem + "-" + asset_id + extension
|
||||
path = os.path.join(model_dir, asset_id + extension)
|
||||
specs.append({
|
||||
"id": asset_id,
|
||||
"label": label,
|
||||
@@ -656,6 +657,8 @@ def export(args):
|
||||
raise RuntimeError("Dynamic traffic signal collection is empty")
|
||||
export_glb(args["dynamic_glb"], dynamic_meshes)
|
||||
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
|
||||
if not args.get(key):
|
||||
continue
|
||||
if not countdown_meshes[group]:
|
||||
raise RuntimeError("Traffic countdown collection %d is empty" % group)
|
||||
export_glb(args[key], countdown_meshes[group])
|
||||
@@ -673,42 +676,27 @@ def export(args):
|
||||
center_lat = (bounds["min_lat"] + bounds["max_lat"]) / 2.0
|
||||
else:
|
||||
center_lon = center_lat = 0.0
|
||||
area_id = os.path.splitext(os.path.basename(args["glb"]))[0]
|
||||
runtime_assets = []
|
||||
if args.get("dynamic_glb"):
|
||||
runtime_assets.append({"id": "traffic-signals-dynamic", "type": "traffic-signal-lenses", "uri": "runtime/traffic-signals-dynamic.glb"})
|
||||
if args.get("countdown_0_glb"):
|
||||
runtime_assets.append({"id": "traffic-signals-countdown-0", "type": "traffic-signal-countdown", "phaseGroup": 0, "uri": "runtime/traffic-signals-countdown-0.glb"})
|
||||
if args.get("countdown_1_glb"):
|
||||
runtime_assets.append({"id": "traffic-signals-countdown-1", "type": "traffic-signal-countdown", "phaseGroup": 1, "uri": "runtime/traffic-signals-countdown-1.glb"})
|
||||
metadata = {
|
||||
"asset": os.path.basename(args["glb"]),
|
||||
"assets": [{
|
||||
"id": "main",
|
||||
"label": "Scene",
|
||||
"type": "model",
|
||||
"url": os.path.basename(args["glb"]),
|
||||
"enabled": True,
|
||||
}, {
|
||||
"id": "traffic-dynamic",
|
||||
"label": "Traffic signals dynamic",
|
||||
"type": "model",
|
||||
"url": os.path.basename(args["dynamic_glb"]) if args.get("dynamic_glb") and dynamic_meshes else "",
|
||||
"enabled": True,
|
||||
"category": "dynamic",
|
||||
}, {
|
||||
"id": "traffic-countdown-0", "label": "Traffic countdown group 0", "type": "model",
|
||||
"url": os.path.basename(args["countdown_0_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 0,
|
||||
}, {
|
||||
"id": "traffic-countdown-1", "label": "Traffic countdown group 1", "type": "model",
|
||||
"url": os.path.basename(args["countdown_1_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 1,
|
||||
}] + [{
|
||||
"id": asset["id"],
|
||||
"label": asset["label"],
|
||||
"type": "model",
|
||||
"url": os.path.basename(asset["path"]),
|
||||
"enabled": False,
|
||||
"category": "semantic",
|
||||
"schema": "osm-asset-package/v1",
|
||||
"packageVersion": "1.0.0",
|
||||
"areaId": area_id,
|
||||
"coordinateSystem": {"axes": "ENU", "units": "meters", "x": "east", "y": "north", "z": "up"},
|
||||
"placement": {"longitude": center_lon, "latitude": center_lat, "height": 0.35, "headingCorrectionDegrees": -90.0},
|
||||
"bounds": {"minLon": bounds.get("min_lon", center_lon), "minLat": bounds.get("min_lat", center_lat), "maxLon": bounds.get("max_lon", center_lon), "maxLat": bounds.get("max_lat", center_lat)},
|
||||
"assets": [{"id": "main", "role": "scene", "category": "scene", "uri": "models/" + os.path.basename(args["glb"]), "defaultLoad": True}] + [{
|
||||
"id": asset["id"], "role": "layer", "category": asset["id"],
|
||||
"uri": "models/" + os.path.basename(asset["path"]), "defaultLoad": False,
|
||||
} for asset in semantic_assets],
|
||||
"coordinate_system": "local ENU meters (X east, Y north, Z up)",
|
||||
"heading_correction_degrees": -90.0,
|
||||
"anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35},
|
||||
"bounds": bounds,
|
||||
"source_osm": scene.get("source_osm", ""),
|
||||
"source_geojson": scene.get("source_geojson", ""),
|
||||
"scene_stats": {
|
||||
"runtime": runtime_assets,
|
||||
"sceneStats": {
|
||||
"buildings": scene.get("building_count", 0),
|
||||
"industrial_buildings": scene.get("industrial_building_count", 0),
|
||||
"lake_polygons": scene.get("lake_count", 0),
|
||||
@@ -717,15 +705,6 @@ def export(args):
|
||||
"scrub_polygons": scene.get("scrub_count", 0),
|
||||
"fountains": scene.get("fountain_count", 0),
|
||||
},
|
||||
"cesium_js": (
|
||||
"const p = Cesium.Cartesian3.fromDegrees(" +
|
||||
f"{center_lon:.8f}, {center_lat:.8f}, 0.35);\n" +
|
||||
"const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);\n" +
|
||||
"const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(-90.0));\n" +
|
||||
"const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());\n" +
|
||||
"Cesium.Model.fromGltfAsync({ url: '" + os.path.basename(args["glb"]) +
|
||||
"', modelMatrix }).then(model => viewer.scene.primitives.add(model));"
|
||||
),
|
||||
}
|
||||
os.makedirs(os.path.dirname(args["metadata"]), exist_ok=True)
|
||||
with open(args["metadata"], "w", encoding="utf-8") as handle:
|
||||
|
||||
@@ -110,7 +110,7 @@ TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLE
|
||||
|
||||
|
||||
def cli_args():
|
||||
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
||||
values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None,
|
||||
"office_overrides": "", "tree_style": "natural"}
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
i = 0
|
||||
@@ -789,8 +789,23 @@ def build(args):
|
||||
_features.dispatch_ways(ways, projector, way_handlers)
|
||||
|
||||
geojson_dir = args.get("geojson")
|
||||
native_road_dir = args.get("native_road")
|
||||
road_counts = {}
|
||||
if geojson_dir and os.path.isdir(geojson_dir):
|
||||
if native_road_dir:
|
||||
if not os.path.isdir(native_road_dir):
|
||||
raise RuntimeError("--native-road directory does not exist: " + native_road_dir)
|
||||
material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS}
|
||||
for source in catalog.NATIVE_ROAD_LAYERS:
|
||||
target = source["material_layer"]
|
||||
layer = material_layers[target]
|
||||
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
|
||||
count = _roads.assemble_geojson_layer(
|
||||
source_path, source["source"], projector, roads_c,
|
||||
road_mats[target], layer["z"])
|
||||
if count == 0:
|
||||
raise RuntimeError("Native road layer has no usable geometry: " + source_path)
|
||||
road_counts[source["source"]] = count
|
||||
elif geojson_dir and os.path.isdir(geojson_dir):
|
||||
for problem in catalog.check_layers(geojson_dir):
|
||||
print("Layer catalog warning:", problem)
|
||||
for layer in catalog.ROAD_LAYERS:
|
||||
@@ -799,7 +814,7 @@ def build(args):
|
||||
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
|
||||
projector, roads_c, road_mats[layer_id], layer["z"])
|
||||
|
||||
if road_counts.get("road_surface", 0) == 0:
|
||||
if not native_road_dir and road_counts.get("road_surface", 0) == 0:
|
||||
_roads.assemble_osm_fallback(
|
||||
ways, projector, roads_c, road_mats["road_surface"])
|
||||
|
||||
@@ -910,6 +925,7 @@ def build(args):
|
||||
scene.render.filepath = args["render"]
|
||||
scene["source_osm"] = args["osm"]
|
||||
scene["source_geojson"] = geojson_dir or ""
|
||||
scene["source_native_road"] = native_road_dir or ""
|
||||
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
||||
scene["building_count"] = counts["building_count"]
|
||||
scene["industrial_building_count"] = counts["industrial_count"]
|
||||
|
||||
@@ -48,6 +48,17 @@ ROAD_LAYERS = [
|
||||
|
||||
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
|
||||
|
||||
# Native-road output intentionally maps into existing scene material layers.
|
||||
# It is a provider adapter, not a second scene-layer registry.
|
||||
NATIVE_ROAD_LAYERS = (
|
||||
{"source": "road_surface", "material_layer": "road_surface"},
|
||||
{"source": "intersection_surface", "material_layer": "intersection_surface"},
|
||||
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
|
||||
{"source": "lane_separators", "material_layer": "lane_separators"},
|
||||
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
)
|
||||
|
||||
|
||||
# Material specs. `kind` selects the builder:
|
||||
# solid — flat base colour
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "osm2streets"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
|
||||
19
examples/asset-package-resolver.js
Normal file
19
examples/asset-package-resolver.js
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
function resolveAsset(manifestUrl, manifest, id = "main") {
|
||||
const asset = manifest.assets.find((candidate) => candidate.id === id);
|
||||
if (!asset) throw new Error(`Unknown asset '${id}'`);
|
||||
return new URL(asset.uri, manifestUrl).href;
|
||||
}
|
||||
|
||||
function resolveRuntime(manifestUrl, manifest, id) {
|
||||
const runtime = (manifest.runtime || []).find((candidate) => candidate.id === id);
|
||||
if (!runtime) throw new Error(`Unknown runtime asset '${id}'`);
|
||||
return new URL(runtime.uri, manifestUrl).href;
|
||||
}
|
||||
|
||||
function placement(manifest) {
|
||||
return { ...manifest.placement, coordinateSystem: manifest.coordinateSystem };
|
||||
}
|
||||
|
||||
module.exports = { resolveAsset, resolveRuntime, placement };
|
||||
20
examples/cesium-asset-package.js
Normal file
20
examples/cesium-asset-package.js
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
const { resolveAsset, resolveRuntime, placement } = require("./asset-package-resolver");
|
||||
|
||||
async function loadPackage(viewer, manifestUrl, assetId = "main") {
|
||||
const manifest = await fetch(manifestUrl).then((response) => {
|
||||
if (!response.ok) throw new Error(`Could not load manifest: ${response.status}`);
|
||||
return response.json();
|
||||
});
|
||||
const p = placement(manifest);
|
||||
const origin = Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude, p.height);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
|
||||
const rotation = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(p.headingCorrectionDegrees));
|
||||
const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, rotation, new Cesium.Matrix4());
|
||||
const model = await Cesium.Model.fromGltfAsync({ url: resolveAsset(manifestUrl, manifest, assetId), modelMatrix });
|
||||
viewer.scene.primitives.add(model);
|
||||
return { manifest, model, placement: p, runtime: (id) => resolveRuntime(manifestUrl, manifest, id) };
|
||||
}
|
||||
|
||||
module.exports = { loadPackage };
|
||||
17
examples/three-asset-package.js
Normal file
17
examples/three-asset-package.js
Normal file
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
const { resolveAsset, resolveRuntime, placement } = require("./asset-package-resolver");
|
||||
|
||||
async function loadPackage(loader, manifestUrl, assetId = "main", handoffEnuPlacement) {
|
||||
const manifest = await fetch(manifestUrl).then((response) => {
|
||||
if (!response.ok) throw new Error(`Could not load manifest: ${response.status}`);
|
||||
return response.json();
|
||||
});
|
||||
const scene = await loader.loadAsync(resolveAsset(manifestUrl, manifest, assetId));
|
||||
// Three.js does not georeference local ENU itself; the embedding host owns it.
|
||||
const enuPlacement = placement(manifest);
|
||||
if (handoffEnuPlacement) handoffEnuPlacement(enuPlacement, scene.scene);
|
||||
return { manifest, scene: scene.scene, placement: enuPlacement, runtime: (id) => resolveRuntime(manifestUrl, manifest, id) };
|
||||
}
|
||||
|
||||
module.exports = { loadPackage };
|
||||
617
package-lock.json
generated
617
package-lock.json
generated
@@ -8,14 +8,631 @@
|
||||
"name": "osm-asset-pipeline",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"ol": "^10.10.0",
|
||||
"osm2streets-js-node": "0.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/ansi": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/ansi/-/ansi-2.0.7.tgz",
|
||||
"integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/checkbox": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/checkbox/-/checkbox-5.2.1.tgz",
|
||||
"integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/confirm": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/confirm/-/confirm-6.1.1.tgz",
|
||||
"integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core": {
|
||||
"version": "11.2.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/core/-/core-11.2.1.tgz",
|
||||
"integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
"@inquirer/type": "^4.0.7",
|
||||
"cli-width": "^4.1.0",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"mute-stream": "^3.0.0",
|
||||
"signal-exit": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/editor": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/editor/-/editor-5.2.2.tgz",
|
||||
"integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/external-editor": "^3.0.3",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/expand": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/expand/-/expand-5.1.1.tgz",
|
||||
"integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/external-editor": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/external-editor/-/external-editor-3.0.3.tgz",
|
||||
"integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chardet": "^2.1.1",
|
||||
"iconv-lite": "^0.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/figures": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/figures/-/figures-2.0.7.tgz",
|
||||
"integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/input": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/input/-/input-5.1.2.tgz",
|
||||
"integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/number": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/number/-/number-4.1.1.tgz",
|
||||
"integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/password": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/password/-/password-5.1.1.tgz",
|
||||
"integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/prompts": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/prompts/-/prompts-8.5.2.tgz",
|
||||
"integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^5.2.1",
|
||||
"@inquirer/confirm": "^6.1.1",
|
||||
"@inquirer/editor": "^5.2.2",
|
||||
"@inquirer/expand": "^5.1.1",
|
||||
"@inquirer/input": "^5.1.2",
|
||||
"@inquirer/number": "^4.1.1",
|
||||
"@inquirer/password": "^5.1.1",
|
||||
"@inquirer/rawlist": "^5.3.1",
|
||||
"@inquirer/search": "^4.2.1",
|
||||
"@inquirer/select": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/rawlist": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/rawlist/-/rawlist-5.3.1.tgz",
|
||||
"integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/search": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/search/-/search-4.2.1.tgz",
|
||||
"integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/select": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/select/-/select-5.2.1.tgz",
|
||||
"integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/type": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "http://172.16.1.86:4873/@inquirer/type/-/type-4.0.7.tgz",
|
||||
"integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "http://172.16.1.86:4873/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/rbush": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "http://172.16.1.86:4873/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zarrita/storage": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "http://172.16.1.86:4873/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
"unzipit": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chardet": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "http://172.16.1.86:4873/chardet/-/chardet-2.2.0.tgz",
|
||||
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "http://172.16.1.86:4873/cli-width/-/cli-width-4.1.0.tgz",
|
||||
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "http://172.16.1.86:4873/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "http://172.16.1.86:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
|
||||
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "http://172.16.1.86:4873/fast-string-width/-/fast-string-width-3.0.2.tgz",
|
||||
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "http://172.16.1.86:4873/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
|
||||
"integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "http://172.16.1.86:4873/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geotiff": {
|
||||
"version": "3.1.0-beta.0",
|
||||
"resolved": "http://172.16.1.86:4873/geotiff/-/geotiff-3.1.0-beta.0.tgz",
|
||||
"integrity": "sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.9.3",
|
||||
"lerc": "^3.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"parse-headers": "^2.0.2",
|
||||
"quick-lru": "^6.1.1",
|
||||
"web-worker": "^1.5.0",
|
||||
"xml-utils": "^1.10.2",
|
||||
"zstddec": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "http://172.16.1.86:4873/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "http://172.16.1.86:4873/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "http://172.16.1.86:4873/mute-stream/-/mute-stream-3.0.0.tgz",
|
||||
"integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/numcodecs": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "http://172.16.1.86:4873/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ol": {
|
||||
"version": "10.10.0",
|
||||
"resolved": "http://172.16.1.86:4873/ol/-/ol-10.10.0.tgz",
|
||||
"integrity": "sha512-tLPKn6zl+6uWdPufYlqG/lQzuVUTVmfwahQqVr5+wZNyZecyAtIhMTyOtKpu7ooNDLY2sEjKZNXw9HL+sOjC1A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
"earcut": "^3.0.0",
|
||||
"geotiff": "^3.1.0-beta.0",
|
||||
"pbf": "5.1.2",
|
||||
"rbush": "^4.0.0",
|
||||
"zarrita": "^0.7.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/osm2streets-js-node": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
|
||||
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "http://172.16.1.86:4873/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "http://172.16.1.86:4873/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "http://172.16.1.86:4873/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "http://172.16.1.86:4873/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "http://172.16.1.86:4873/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "http://172.16.1.86:4873/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "http://172.16.1.86:4873/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reference-spec-reader": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "http://172.16.1.86:4873/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "http://172.16.1.86:4873/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "http://172.16.1.86:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "http://172.16.1.86:4873/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipit": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "http://172.16.1.86:4873/unzipit/-/unzipit-2.0.0.tgz",
|
||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "http://172.16.1.86:4873/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "http://172.16.1.86:4873/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/zarrita": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "http://172.16.1.86:4873/zarrita/-/zarrita-0.7.4.tgz",
|
||||
"integrity": "sha512-NChufj86SceFj45z4CYjndmI98dphaG7hU8Wj0UGpMyT21o0V3HTY6zo87aSobYWts1tNFivSXzH2AR1AkaWjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.2.0",
|
||||
"numcodecs": "^0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/zstddec": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "http://172.16.1.86:4873/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
43
scripts/check-native-roads.js
Normal file
43
scripts/check-native-roads.js
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { compileArea, parseArgs } = require("./compile-native-roads");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function checkArea(configPath, options = {}) {
|
||||
if (options.compile) compileArea(configPath);
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const root = area.outputs.nativeRoadDir;
|
||||
const compiledPath = path.join(root, "compiled.json");
|
||||
if (!fs.existsSync(compiledPath)) throw new Error(`Native road output is missing: ${compiledPath}`);
|
||||
const compiled = readJson(compiledPath);
|
||||
const connectors = readJson(path.join(root, "layers", "connectors.geojson"));
|
||||
const published = new Set(connectors.features.map((feature) => feature.properties.movement_id));
|
||||
const failures = [];
|
||||
for (const movement of compiled.movements || []) {
|
||||
if (movement.geometryPublished && !published.has(movement.id)) failures.push(`Published movement has no connector: ${movement.id}`);
|
||||
if (!movement.geometryPublished && published.has(movement.id)) failures.push(`Non-published movement has a connector: ${movement.id}`);
|
||||
if (!movement.geometryStatus) failures.push(`Movement has no geometry status: ${movement.id}`);
|
||||
}
|
||||
const errors = (compiled.diagnostics || []).filter((item) => item.severity === "error");
|
||||
const warnings = (compiled.diagnostics || []).filter((item) => item.severity === "warning");
|
||||
return { schema: "native-road-check/v1", areaId: area.id, ok: failures.length === 0 && errors.length === 0, movementCount: (compiled.movements || []).length, connectorCount: connectors.features.length, errors: errors.map((item) => ({ id: item.id, rule: item.rule, message: item.message })), warningCount: warnings.length, failures };
|
||||
}
|
||||
|
||||
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||
const result = checkArea(configPath, { compile: args.compile === "true" });
|
||||
console.log(`NATIVE_ROAD_CHECK_DONE ${JSON.stringify(result)}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { checkArea };
|
||||
112
scripts/compile-native-roads.js
Normal file
112
scripts/compile-native-roads.js
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (!argv[index].startsWith("--")) continue;
|
||||
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileArea(configPath) {
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
||||
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
||||
validateOverrides(overrides, model);
|
||||
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
|
||||
const compiled = compileGeometry(model, overrides);
|
||||
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
|
||||
try {
|
||||
const result = {
|
||||
schema: "native-road-compiled/v1",
|
||||
areaId: area.id,
|
||||
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
|
||||
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
|
||||
movements: compiled.movements,
|
||||
diagnostics: compiled.diagnostics,
|
||||
layers: { roadSurface: "layers/road_surface.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", connectors: "layers/connectors.geojson" },
|
||||
};
|
||||
const comparison = compareOsm2Streets(area, result.model, compiled);
|
||||
writeJsonAtomic(path.join(staging, "compiled.json"), result);
|
||||
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
|
||||
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
|
||||
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
|
||||
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
|
||||
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
|
||||
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
|
||||
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
|
||||
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
|
||||
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
|
||||
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
|
||||
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||
return { area, result, comparison };
|
||||
} catch (error) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function compareOsm2Streets(area, model, compiled) {
|
||||
const source = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
let featureCount = null;
|
||||
if (fs.existsSync(source)) {
|
||||
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
|
||||
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
|
||||
}
|
||||
const diagnosticsBySeverity = {};
|
||||
const diagnosticsByRule = {};
|
||||
for (const item of compiled.diagnostics) {
|
||||
diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1;
|
||||
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
|
||||
}
|
||||
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
|
||||
const junctions = compiled.intersectionSurface.features;
|
||||
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
|
||||
return {
|
||||
schema: "native-road-comparison/v2",
|
||||
nativeRoadCount: model.roads.length,
|
||||
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
|
||||
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
|
||||
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
|
||||
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
|
||||
nativeFallbackJunctions: fallbackJunctions.length,
|
||||
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
|
||||
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
|
||||
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
|
||||
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
|
||||
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
|
||||
nativeConnectorFeatures: compiled.connectors.features.length,
|
||||
nativeMovementCount: compiled.movements.length,
|
||||
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
|
||||
nativeConnectionCount: model.connections.length,
|
||||
unconnectedInteriorRoadEnds: dangling.length,
|
||||
unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length,
|
||||
diagnosticsBySeverity,
|
||||
diagnosticsByRule,
|
||||
osm2streetsRoadSurfaceFeatures: featureCount,
|
||||
osm2streetsAvailable: featureCount !== null,
|
||||
note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.",
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||
const { area, result, comparison } = compileArea(configPath);
|
||||
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { compileArea, parseArgs };
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
// Experimental GLB compression wrapper. Keeps the source GLB intact and writes
|
||||
// a separate compressed artifact for visual comparison in Cesium.
|
||||
// GLB compression wrapper. The caller supplies distinct input and output files;
|
||||
// build-area.js uses staging files before promoting the compressed delivery.
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
@@ -143,30 +143,25 @@ function previewOutputPath(output, explicitPath) {
|
||||
function writeCompressedMetadata(sourceMetadata, outputGlb, outputMetadata) {
|
||||
const metadata = JSON.parse(fs.readFileSync(sourceMetadata, "utf8"));
|
||||
const glbName = path.basename(outputGlb);
|
||||
metadata.asset = glbName;
|
||||
if (metadata.asset !== undefined) metadata.asset = glbName;
|
||||
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
|
||||
const main = assets.find((asset) => asset.id === "main");
|
||||
metadata.assets = assets.length
|
||||
? assets.map((asset) => asset.id === "main" ? {
|
||||
...asset,
|
||||
label: "Compressed Scene",
|
||||
url: glbName,
|
||||
enabled: true,
|
||||
...(Object.hasOwn(asset, "uri") ? { uri: `models/${glbName}`, defaultLoad: true } : { url: glbName, enabled: true }),
|
||||
} : asset)
|
||||
: [{
|
||||
id: "main",
|
||||
label: "Compressed Scene",
|
||||
type: "model",
|
||||
url: glbName,
|
||||
enabled: true,
|
||||
role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: true,
|
||||
}];
|
||||
if (!main && assets.length) {
|
||||
metadata.assets.unshift({
|
||||
id: "main",
|
||||
label: "Compressed Scene",
|
||||
type: "model",
|
||||
url: glbName,
|
||||
enabled: true,
|
||||
role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: true,
|
||||
});
|
||||
}
|
||||
if (typeof metadata.cesium_js === "string") {
|
||||
|
||||
49
scripts/interactive-build.js
Normal file
49
scripts/interactive-build.js
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { STAGES, resolveStages, canonicalStages } = require("./lib/build-stages");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function areaConfigs() {
|
||||
return fs.readdirSync(path.join(repoRoot, "config", "areas"))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort()
|
||||
.map((file) => path.join(repoRoot, "config", "areas", file));
|
||||
}
|
||||
|
||||
function requireTty(input = process.stdin, output = process.stdout) {
|
||||
if (!input.isTTY || !output.isTTY) throw new Error("Interactive build requires a TTY. Use npm run build:area -- --config <area> --stages <stages> instead.");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
requireTty();
|
||||
const configs = areaConfigs();
|
||||
if (!configs.length) throw new Error("No area configs found in config/areas.");
|
||||
const { select, checkbox } = await import("@inquirer/prompts");
|
||||
const config = await select({
|
||||
message: "Choose an area",
|
||||
choices: configs.map((file) => ({ name: path.basename(file, ".json"), value: file })),
|
||||
});
|
||||
const chosen = await checkbox({
|
||||
message: "Choose build stages",
|
||||
choices: STAGES.map((stage) => ({ name: `${stage.id} - ${stage.description}`, value: stage.id })),
|
||||
required: true,
|
||||
});
|
||||
if (!chosen.length) throw new Error("Choose at least one build stage.");
|
||||
const stages = canonicalStages(resolveStages({}, chosen));
|
||||
console.log(`Area: ${path.basename(config)}`);
|
||||
console.log(`Stages: ${stages.join(", ")}`);
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, "build-area.js"), "--config", config, "--stages", stages.join(",")], { stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status || 0;
|
||||
}
|
||||
|
||||
if (require.main === module) main().catch((error) => {
|
||||
if (error.name !== "ExitPromptError") console.error(`Error: ${error.message}`);
|
||||
process.exitCode = error.name === "ExitPromptError" ? 0 : 1;
|
||||
});
|
||||
module.exports = { areaConfigs, requireTty };
|
||||
@@ -25,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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
43
scripts/lib/build-stages.js
Normal file
43
scripts/lib/build-stages.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
const STAGES = [
|
||||
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
|
||||
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
|
||||
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
|
||||
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
|
||||
{ id: "compress", label: "Compress staged package", description: "Compress the staged primary GLB" },
|
||||
{ id: "package", label: "Publish asset package", description: "Validate and atomically publish package/" },
|
||||
{ id: "preview", label: "Preview refresh", description: "Regenerate local verification preview and dynamic assets" },
|
||||
];
|
||||
|
||||
const ALIASES = {
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
|
||||
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
|
||||
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
|
||||
compress: ["compress"], compression: ["compress"], compressedCesium: ["compress"],
|
||||
package: ["package"], publish: ["package"], assetPackage: ["package"],
|
||||
};
|
||||
|
||||
function resolveStages(defaults, requested) {
|
||||
const selected = requested || Object.keys(defaults).filter((key) => defaults[key]);
|
||||
const result = Object.fromEntries(STAGES.map(({ id }) => [id, false]));
|
||||
for (const stage of selected) {
|
||||
const mapped = ALIASES[stage];
|
||||
if (!mapped) throw new Error(`Unknown stage '${stage}'. Use ${stageNames().join(", ")}, or all.`);
|
||||
for (const id of mapped) result[id] = true;
|
||||
}
|
||||
assertCompatible(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertCompatible(stages) {
|
||||
if (stages.intermediates && stages.reimport) {
|
||||
throw new Error("Stages 'intermediates' and 'reimport' are mutually exclusive: intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.");
|
||||
}
|
||||
}
|
||||
|
||||
function stageNames() { return STAGES.map(({ id }) => id); }
|
||||
function canonicalStages(stages) { return stageNames().filter((id) => stages[id]); }
|
||||
|
||||
module.exports = { STAGES, resolveStages, canonicalStages, stageNames };
|
||||
@@ -20,7 +20,8 @@ body.scene-ready #cesiumContainer {
|
||||
|
||||
#controls,
|
||||
#diagnostics,
|
||||
#status {
|
||||
#status,
|
||||
#vehicleInfoCard {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
@@ -160,6 +161,96 @@ body.scene-error .loading-bar span {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
#vehicleInfoCard {
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: min(300px, calc(100vw - 24px));
|
||||
padding: 10px 12px;
|
||||
transform: translate(-50%, calc(-100% - 18px));
|
||||
transform-origin: bottom center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#vehicleInfoCard::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
content: "";
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: rgba(20, 24, 28, 0.82);
|
||||
}
|
||||
|
||||
.vehicle-card-heading,
|
||||
.vehicle-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vehicle-card-heading strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#vehicleInfoCard button {
|
||||
height: 27px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
background: #f2f5f7;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#vehicleInfoDetails {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 4px 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#vehicleInfoDetails dt {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
#vehicleInfoDetails dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vehicle-card-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
#vehicleIncidentNote {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.vehicle-card-actions {
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vehicle-card-actions button[data-vehicle-status="breakdown"] {
|
||||
background: #f3c84b;
|
||||
}
|
||||
|
||||
.vehicle-card-actions button[data-vehicle-status="accident"] {
|
||||
background: #e95950;
|
||||
}
|
||||
|
||||
#controls button,
|
||||
#controls select {
|
||||
height: 28px;
|
||||
@@ -285,4 +376,5 @@ body.scene-error .loading-bar span {
|
||||
bottom: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
743
scripts/lib/native-road.js
Normal file
@@ -0,0 +1,743 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
|
||||
|
||||
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
|
||||
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
|
||||
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
|
||||
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
|
||||
const DIRECTION_ARROW_INTERVAL_METERS = 32;
|
||||
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
|
||||
|
||||
function parseOsmRoads(xml) {
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate);
|
||||
}
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = parseTags(body);
|
||||
if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue;
|
||||
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
|
||||
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
|
||||
if (coords.length < 2 || coords.length !== refs.length) continue;
|
||||
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
|
||||
}
|
||||
return { nodes, ways };
|
||||
}
|
||||
|
||||
function compileRoadModel(xml, overrides) {
|
||||
const parsed = parseOsmRoads(xml);
|
||||
const diagnostics = [];
|
||||
const roads = [];
|
||||
const endpoints = [];
|
||||
const byNode = new Map();
|
||||
const sharedNodeWayIds = new Map();
|
||||
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
|
||||
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
|
||||
sharedNodeWayIds.get(nodeId).add(way.id);
|
||||
}
|
||||
for (const sourceWay of parsed.ways) {
|
||||
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
|
||||
for (const way of segments) {
|
||||
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
|
||||
for (const direction of directions) {
|
||||
const base = roadAttributes(way.tags, direction);
|
||||
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
|
||||
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
|
||||
applyRoadOverrides(road, overrides, diagnostics);
|
||||
roads.push(road);
|
||||
for (const side of ["start", "end"]) {
|
||||
const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
|
||||
const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction };
|
||||
endpoints.push(endpoint);
|
||||
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
|
||||
byNode.get(nodeId).push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
|
||||
const extent = roadExtent(roads);
|
||||
for (const [nodeId, items] of byNode) {
|
||||
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
|
||||
const endpoint = items[0];
|
||||
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) });
|
||||
}
|
||||
}
|
||||
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
|
||||
}
|
||||
|
||||
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
|
||||
const splitIndexes = [0];
|
||||
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
|
||||
splitIndexes.push(way.refs.length - 1);
|
||||
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
|
||||
return splitIndexes.slice(1).map((end, index) => {
|
||||
const start = splitIndexes[index];
|
||||
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
function roadExtent(roads) {
|
||||
const points = roads.flatMap((road) => road.centerline);
|
||||
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
|
||||
}
|
||||
|
||||
function distanceToExtentEdgeMeters(point, extent) {
|
||||
const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180);
|
||||
return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320);
|
||||
}
|
||||
|
||||
function roadAttributes(tags, direction) {
|
||||
const directional = direction === "forward" ? "forward" : "backward";
|
||||
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null);
|
||||
const parsedLanes = positiveInteger(laneTag);
|
||||
const totalLanes = positiveInteger(tags.lanes);
|
||||
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1);
|
||||
const parsedWidth = positiveNumber(tags.width);
|
||||
const forwardLanes = positiveInteger(tags["lanes:forward"]);
|
||||
const backwardLanes = positiveInteger(tags["lanes:backward"]);
|
||||
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
|
||||
// `width` describes the whole OSM way. A directional road receives its lane
|
||||
// share; absent width falls back to a realistic per-lane carriageway width.
|
||||
const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25;
|
||||
return {
|
||||
laneCount: lanes,
|
||||
widthMeters: width,
|
||||
sidewalkLeft: sidewalkState(tags, direction, "left"),
|
||||
sidewalkRight: sidewalkState(tags, direction, "right"),
|
||||
provenance: {
|
||||
laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes",
|
||||
widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sidewalkState(tags, direction, side) {
|
||||
const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left";
|
||||
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
|
||||
return value === "both" || value === "yes" || value === osmSide;
|
||||
}
|
||||
|
||||
function loadOverrides(file) {
|
||||
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
|
||||
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
|
||||
function validateOverrides(value, model) {
|
||||
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
|
||||
const ids = new Set();
|
||||
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
|
||||
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
|
||||
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
|
||||
for (const item of value.overrides) {
|
||||
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
|
||||
ids.add(item.id);
|
||||
if (item.kind === "road") {
|
||||
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
|
||||
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
|
||||
} else if (item.kind === "junction-connection") {
|
||||
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
|
||||
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
|
||||
} else if (item.kind === "lane-connection") {
|
||||
if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override.");
|
||||
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
||||
}
|
||||
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
|
||||
}
|
||||
|
||||
function applyRoadOverrides(road, overrides, diagnostics) {
|
||||
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
|
||||
// A legacy whole-way edit remains the baseline; a segment-specific edit can
|
||||
// deliberately refine it after the compiler has introduced split segments.
|
||||
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
|
||||
for (const item of matching) {
|
||||
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
|
||||
road.appliedOverrideIds.push(item.id);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
|
||||
}
|
||||
if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0]));
|
||||
}
|
||||
|
||||
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
||||
const result = [];
|
||||
for (const [nodeId, items] of byNode) {
|
||||
const arrivals = items.filter((endpoint) => endpoint.side === "end");
|
||||
const departures = items.filter((endpoint) => endpoint.side === "start");
|
||||
for (const arrival of arrivals) for (const departure of departures) {
|
||||
if (arrival.roadId === departure.roadId) continue;
|
||||
const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
|
||||
const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
|
||||
if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
|
||||
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
|
||||
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
|
||||
}
|
||||
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
|
||||
}
|
||||
// Overrides can add a deliberate movement omitted by the initial inference.
|
||||
// Keep it only when both endpoints still belong to the same OSM junction.
|
||||
for (const override of overrides.overrides.filter((item) => item.kind === "junction-connection")) {
|
||||
const exists = result.some((connection) => connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId);
|
||||
if (exists) continue;
|
||||
const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
|
||||
const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
|
||||
if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
|
||||
result.push({ id: `connection:${from.id}:${to.id}`, nodeId: from.nodeId, fromEndpointId: from.id, toEndpointId: to.id, enabled: override.enabled, provenance: `override:${override.id}` });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
|
||||
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
|
||||
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
|
||||
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
|
||||
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
|
||||
}
|
||||
function connectionEndpointsCompatible(model, fromId, toId) {
|
||||
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
|
||||
const to = model.endpoints.find((endpoint) => endpoint.id === toId);
|
||||
if (!from || !to || from.roadId === to.roadId || sameOsmWay(model.endpoints, from.roadId, to.roadId) || from.side !== "end" || to.side !== "start") return false;
|
||||
if (from.nodeId === to.nodeId) return true;
|
||||
const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180);
|
||||
const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
|
||||
return Math.hypot(dx, dy) <= 35;
|
||||
}
|
||||
|
||||
function nearbyManualCandidates(endpoints, from) {
|
||||
return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 }));
|
||||
}
|
||||
|
||||
function compileGeometry(model, overrides = { overrides: [] }) {
|
||||
const diagnostics = [...model.diagnostics];
|
||||
const junctionPlans = compileJunctionPlans(model);
|
||||
const features = [];
|
||||
const emittedSegments = new Set();
|
||||
for (const road of model.roads) {
|
||||
const segmentKey = road.segmentId;
|
||||
if (emittedSegments.has(segmentKey)) continue;
|
||||
emittedSegments.add(segmentKey);
|
||||
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
||||
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||
// Road and junction asphalt share one final material. Keep the carriageway
|
||||
// continuous through the semantic junction overlay; cutting it back creates
|
||||
// visible wedges/gaps without improving the rendered result.
|
||||
const ring = roadRing(road.centerline, totalWidth);
|
||||
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
|
||||
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
|
||||
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
|
||||
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans);
|
||||
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
|
||||
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
|
||||
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
|
||||
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
|
||||
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
|
||||
}
|
||||
|
||||
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
const separators = []; const directionArrows = []; const turnArrows = [];
|
||||
for (const road of model.roads) {
|
||||
const roadLanes = lanes.byRoadId.get(road.id) || [];
|
||||
for (let index = 1; index < roadLanes.length; index += 1) {
|
||||
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
|
||||
if (left.length !== right.length) continue;
|
||||
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
|
||||
const ring = roadRing(centerline, 0.12);
|
||||
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane));
|
||||
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
|
||||
const maneuvers = turns ? String(turns).split("|") : [];
|
||||
for (let index = 0; index < roadLanes.length; index += 1) {
|
||||
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
|
||||
if (!explicitManeuver) continue;
|
||||
const maneuver = normalizeManeuver(explicitManeuver);
|
||||
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
|
||||
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
|
||||
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
|
||||
const center = pointAlongLine([...lane.coordinates].reverse(), 6);
|
||||
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
|
||||
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
|
||||
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
|
||||
const rings = axis ? arrowRingsAt(maneuver, center, axis) : [];
|
||||
if (!rings.length) continue;
|
||||
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: 6, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
}
|
||||
}
|
||||
return { separators, directionArrows, turnArrows };
|
||||
}
|
||||
|
||||
function directionArrowFeatures(road, lane) {
|
||||
const length = lineLengthMeters(lane.coordinates);
|
||||
const features = [];
|
||||
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
|
||||
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
|
||||
if (!placement) continue;
|
||||
const rings = arrowRingsAt("through", placement.point, placement.axis);
|
||||
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
|
||||
const features = [];
|
||||
const byWay = new Map();
|
||||
for (const road of model.roads) {
|
||||
const key = road.segmentId;
|
||||
if (!byWay.has(key)) byWay.set(key, []);
|
||||
byWay.get(key).push(road);
|
||||
}
|
||||
for (const [wayKey, directions] of byWay) {
|
||||
const forward = directions.find((road) => road.direction === "forward") || directions[0];
|
||||
const backward = directions.find((road) => road.id !== forward.id);
|
||||
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
|
||||
const sides = [
|
||||
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
|
||||
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
|
||||
];
|
||||
for (const [side, enabled] of sides) {
|
||||
if (!enabled) continue;
|
||||
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
||||
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
|
||||
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
|
||||
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
|
||||
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
}
|
||||
features.push(...compileSidewalkCorners(model, junctionPlans));
|
||||
return features;
|
||||
}
|
||||
|
||||
function compileSidewalkCorners(model, junctionPlans) {
|
||||
const result = [];
|
||||
for (const [nodeId, plan] of junctionPlans) {
|
||||
const candidates = [];
|
||||
for (const approach of plan.approaches) {
|
||||
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
|
||||
const forward = directions.find((road) => road.direction === "forward") || directions[0];
|
||||
if (!forward) continue;
|
||||
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
|
||||
const sideStates = outwardIsForward
|
||||
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
|
||||
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
|
||||
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
|
||||
if (!cutback) continue;
|
||||
const heading = headingAtEndpoint(approach.line);
|
||||
const halfWidth = approach.widthMeters / 2;
|
||||
for (const [side, enabled] of Object.entries(sideStates)) {
|
||||
if (!enabled) continue;
|
||||
// offsetLine's positive normal is driver's left, which is heading -90
|
||||
// in this north-based heading convention.
|
||||
const sideHeading = heading + (side === "left" ? -90 : 90);
|
||||
candidates.push({
|
||||
wayKey: approach.segmentId,
|
||||
sourceWayKey: forward.osmWayIds.join(","),
|
||||
side,
|
||||
normalDegrees: sideHeading,
|
||||
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
|
||||
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
|
||||
});
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const first = candidates[index];
|
||||
const second = candidates[(index + 1) % candidates.length];
|
||||
if (first.wayKey === second.wayKey) continue;
|
||||
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
|
||||
if (hasSelfIntersection(ring)) continue;
|
||||
if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue;
|
||||
result.push({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
|
||||
osm_node_id: nodeId,
|
||||
kind: "corner",
|
||||
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
||||
provenance: "native-road-sidewalk-corner/v1",
|
||||
},
|
||||
geometry: { type: "Polygon", coordinates: [ring] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function samePhysicalSide(first, second) {
|
||||
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
|
||||
return Math.cos(radians) >= 0.98;
|
||||
}
|
||||
|
||||
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
|
||||
const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
|
||||
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
|
||||
const carriageway = roadRing(approach.line, approach.widthMeters);
|
||||
return carriageway && pointInPolygon(center, carriageway);
|
||||
});
|
||||
}
|
||||
|
||||
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
|
||||
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
|
||||
for (const connector of connectors) {
|
||||
const junction = junctionByNode.get(connector.properties.node_id);
|
||||
if (!junction) continue;
|
||||
const ring = junction.geometry.coordinates[0];
|
||||
if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
|
||||
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(point, ring) {
|
||||
for (let index = 1; index < ring.length; index += 1) if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const a = ring[index]; const b = ring[previous];
|
||||
const intersect = a[1] > point[1] !== b[1] > point[1] && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0];
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
function pointOnSegment(point, a, b) {
|
||||
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
|
||||
if (Math.abs(cross) > 1e-12) return false;
|
||||
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
|
||||
}
|
||||
|
||||
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
|
||||
const features = [];
|
||||
const byRoadId = new Map();
|
||||
for (const road of model.roads) {
|
||||
const lanes = [];
|
||||
const laneWidth = road.widthMeters / road.laneCount;
|
||||
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
|
||||
const opposite = siblings.find((item) => item.id !== road.id);
|
||||
// OSM centerline is the shared carriageway center. On a two-way road,
|
||||
// offset each directed carriageway to its own side before placing lanes.
|
||||
const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
|
||||
for (let index = 0; index < road.laneCount; index += 1) {
|
||||
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
|
||||
// driver's left so tag positions and generated lane IDs have one meaning.
|
||||
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
|
||||
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
|
||||
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
|
||||
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
|
||||
lanes.push(lane);
|
||||
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, source: "native-road-lane-centerline/v1" }, geometry: { type: "LineString", coordinates } });
|
||||
}
|
||||
byRoadId.set(road.id, lanes);
|
||||
}
|
||||
return { features, byRoadId };
|
||||
}
|
||||
|
||||
function compileConnectors(model, lanes, diagnostics, overrides) {
|
||||
const features = [];
|
||||
const movements = [];
|
||||
for (const connection of model.connections.filter((item) => item.enabled)) {
|
||||
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
|
||||
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
|
||||
const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
|
||||
const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
|
||||
if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; }
|
||||
for (let index = 0; index < fromLanes.length; index += 1) {
|
||||
const turn = connectionTurn(fromRoad, toRoad);
|
||||
const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
|
||||
const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex];
|
||||
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
|
||||
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
|
||||
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
|
||||
const control = connectorControlPoint(model, connection, from, to);
|
||||
const coordinates = quadraticCurve(from, control, to, 12);
|
||||
const length = lineLengthMeters(coordinates);
|
||||
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
|
||||
const provenance = override ? `override:${override.id}` : connection.provenance;
|
||||
const connectorId = `connector:${id}`;
|
||||
const geometryStatus = length < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector";
|
||||
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
|
||||
if (length < .4) { movements.push(movement); continue; }
|
||||
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
|
||||
movements.push(movement);
|
||||
}
|
||||
}
|
||||
return { features, movements };
|
||||
}
|
||||
function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); }
|
||||
|
||||
function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; }
|
||||
function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; }
|
||||
function connectionTurn(fromRoad, toRoad) {
|
||||
if (!fromRoad || !toRoad) return "unknown";
|
||||
const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
|
||||
const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
|
||||
const delta = ((outgoing - incoming + 540) % 360) - 180;
|
||||
if (Math.abs(delta) >= 150) return "uturn";
|
||||
if (Math.abs(delta) <= 30) return "through";
|
||||
return delta > 0 ? "right" : "left";
|
||||
}
|
||||
function laneAllowsTurn(road, zeroIndex, turn) {
|
||||
if (!road) return true;
|
||||
const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
|
||||
if (!tag) return true;
|
||||
const lanes = String(tag).split("|").map((lane) => lane.split(";").map((value) => value.trim().replace("slight_", "")).filter(Boolean));
|
||||
const allowed = lanes[zeroIndex];
|
||||
return !allowed || allowed.includes(turn) || turn === "uturn" && allowed.includes("reverse");
|
||||
}
|
||||
function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
|
||||
if (turn === "left") return 0;
|
||||
if (turn === "right") return targetCount - 1;
|
||||
if (turn === "uturn") return 0;
|
||||
return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
|
||||
}
|
||||
function connectorControlPoint(model, connection, from, to) {
|
||||
const node = endpointCoordinate(model, connection.fromEndpointId);
|
||||
if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
|
||||
// Nearby manual joins may not share exactly the same point. The midpoint
|
||||
// keeps their curve smooth without rewriting the authoritative OSM geometry.
|
||||
return node;
|
||||
}
|
||||
|
||||
function quadraticCurve(a, control, b, segments) {
|
||||
const result = [];
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const t = index / segments; const u = 1 - t;
|
||||
result.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function offsetLine(line, offsetMeters) {
|
||||
if (line.length < 2) return null;
|
||||
const origin = line[0]; const points = line.map((point) => project(point, origin)); const result = [];
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)];
|
||||
const dx = next[0] - previous[0]; const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy);
|
||||
if (length < 0.01) return null;
|
||||
result.push(unproject([points[index][0] - dy / length * offsetMeters, points[index][1] + dx / length * offsetMeters], origin));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
|
||||
function polygonAreaMeters(ring) {
|
||||
if (ring.length < 3) return 0;
|
||||
const origin = ring[0];
|
||||
const points = ring.map((point) => project(point, origin));
|
||||
let twiceArea = 0;
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const next = points[(index + 1) % points.length];
|
||||
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
|
||||
}
|
||||
return Math.abs(twiceArea) / 2;
|
||||
}
|
||||
|
||||
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
|
||||
const result = [];
|
||||
for (const [nodeId, plan] of junctionPlans) {
|
||||
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
|
||||
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
|
||||
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
|
||||
if (boundary.length < 3 || !junctionMovements.length) {
|
||||
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
|
||||
continue;
|
||||
}
|
||||
const approachAreaMeters = polygonAreaMeters(boundary);
|
||||
let ring = [...boundary, boundary[0]];
|
||||
let boundaryMode = "approach-envelope";
|
||||
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
|
||||
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
|
||||
ring = [...envelope, envelope[0]];
|
||||
boundaryMode = "connector-convex-fallback";
|
||||
}
|
||||
if (hasSelfIntersection(ring)) {
|
||||
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
|
||||
continue;
|
||||
}
|
||||
const surfaceAreaMeters = polygonAreaMeters(ring);
|
||||
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileJunctionPlans(model) {
|
||||
const byNode = new Map();
|
||||
for (const endpoint of model.endpoints) {
|
||||
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
||||
byNode.get(endpoint.nodeId).push(endpoint);
|
||||
}
|
||||
const plans = new Map();
|
||||
for (const [nodeId, endpoints] of byNode) {
|
||||
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
|
||||
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
|
||||
const approaches = junctionApproaches(model, endpoints);
|
||||
if (approaches.length !== segmentIds.size) continue;
|
||||
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||
const node = endpoints[0].coordinate;
|
||||
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
||||
if (boundary.length < 3) continue;
|
||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
function junctionApproaches(model, endpoints) {
|
||||
const groups = new Map();
|
||||
for (const endpoint of endpoints) {
|
||||
const road = model.roads.find((item) => item.id === endpoint.roadId);
|
||||
if (!road) continue;
|
||||
const key = road.segmentId;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push({ endpoint, road });
|
||||
}
|
||||
return [...groups.values()].map((directions) => {
|
||||
const { endpoint, road } = directions[0];
|
||||
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
|
||||
});
|
||||
}
|
||||
|
||||
function junctionBoundary(approaches, node, cutbackMeters) {
|
||||
const points = [];
|
||||
for (const approach of approaches) {
|
||||
const cutback = pointAlongLine(approach.line, cutbackMeters);
|
||||
if (!cutback) continue;
|
||||
const heading = headingAtEndpoint(approach.line);
|
||||
const half = approach.widthMeters / 2;
|
||||
points.push(offsetCoordinate(cutback, heading + 90, half));
|
||||
points.push(offsetCoordinate(cutback, heading - 90, half));
|
||||
}
|
||||
return sortAround(node, points);
|
||||
}
|
||||
|
||||
function pointAlongLine(line, meters) {
|
||||
let remaining = meters;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const length = distanceMeters(line[index - 1], line[index]);
|
||||
if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
|
||||
remaining -= length;
|
||||
}
|
||||
return line.at(-1);
|
||||
}
|
||||
|
||||
function pointAndAxisAlongLine(line, meters) {
|
||||
let remaining = meters;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const start = line[index - 1]; const end = line[index];
|
||||
const length = distanceMeters(start, end);
|
||||
if (length < 0.01) continue;
|
||||
if (length >= remaining) {
|
||||
const vector = project(end, start);
|
||||
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
|
||||
}
|
||||
remaining -= length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
|
||||
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
|
||||
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
|
||||
if (!startCutback && !endCutback) return line;
|
||||
const total = lineLengthMeters(line);
|
||||
// Short OSM fragments cannot safely lose both ends. Keep their source
|
||||
// geometry intact and let the junction diagnostic surface the ambiguity.
|
||||
if (startCutback + endCutback >= total - 0.5) return line;
|
||||
const result = [];
|
||||
let traversed = 0;
|
||||
const start = pointAlongLine(line, startCutback);
|
||||
const end = pointAlongLine(line, total - endCutback);
|
||||
result.push(start);
|
||||
for (let index = 1; index < line.length - 1; index += 1) {
|
||||
traversed += distanceMeters(line[index - 1], line[index]);
|
||||
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
|
||||
}
|
||||
result.push(end);
|
||||
return result;
|
||||
}
|
||||
|
||||
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
|
||||
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
|
||||
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
|
||||
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
|
||||
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
|
||||
function convexHull(points) {
|
||||
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (unique.length < 3) return unique;
|
||||
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
const lower = []; for (const point of unique) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop(); lower.push(point); }
|
||||
const upper = []; for (const point of [...unique].reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop(); upper.push(point); }
|
||||
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||
}
|
||||
function interpolate(a, b, ratio) { return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio]; }
|
||||
function distanceMeters(a, b) { const dx = (b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180); const dy = (b[1] - a[1]) * 111320; return Math.hypot(dx, dy); }
|
||||
function hasSelfIntersection(ring) {
|
||||
for (let first = 0; first < ring.length - 1; first += 1) for (let second = first + 1; second < ring.length - 1; second += 1) {
|
||||
if (Math.abs(first - second) <= 1 || first === 0 && second === ring.length - 2) continue;
|
||||
if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
const abC = cross(a, b, c); const abD = cross(a, b, d); const cdA = cross(c, d, a); const cdB = cross(c, d, b);
|
||||
return (abC > 0 && abD < 0 || abC < 0 && abD > 0) && (cdA > 0 && cdB < 0 || cdA < 0 && cdB > 0);
|
||||
}
|
||||
|
||||
function circleRing(center, radius, segments) {
|
||||
const origin = center;
|
||||
const ring = [];
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const angle = index / segments * Math.PI * 2;
|
||||
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
function roadRing(line, width) {
|
||||
if (line.length < 2 || !Number.isFinite(width)) return null;
|
||||
const origin = line[0];
|
||||
const points = line.map((point) => project(point, origin));
|
||||
const left = []; const right = [];
|
||||
const half = width / 2;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)];
|
||||
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
|
||||
if (length < 0.01) return null;
|
||||
const nx = -dy / length * half; const ny = dx / length * half;
|
||||
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin));
|
||||
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin));
|
||||
}
|
||||
const ring = [...left, ...right.reverse(), left[0]];
|
||||
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
||||
}
|
||||
|
||||
function sidewalkRing(line, innerOffset, outerOffset, side) {
|
||||
const inner = offsetLine(line, innerOffset * side);
|
||||
const outer = offsetLine(line, outerOffset * side);
|
||||
if (!inner || !outer) return null;
|
||||
const ring = [...inner, ...outer.reverse(), inner[0]];
|
||||
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
||||
}
|
||||
|
||||
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
|
||||
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
|
||||
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }
|
||||
function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; }
|
||||
function parseTags(body) { const tags = {}; for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; }
|
||||
function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; }
|
||||
function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; }
|
||||
function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); }
|
||||
|
||||
module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };
|
||||
82
scripts/lib/package-contract.js
Normal file
82
scripts/lib/package-contract.js
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const SCHEMA = "osm-asset-package/v1";
|
||||
const CATEGORIES = new Set(["scene", "roads", "buildings", "vegetation", "water"]);
|
||||
const ROLES = new Set(["scene", "layer"]);
|
||||
|
||||
function relativeUri(uri) {
|
||||
if (typeof uri !== "string" || !uri || uri.startsWith("/") || path.isAbsolute(uri) || uri.includes("\\") || uri.split("/").includes("..")) {
|
||||
throw new Error(`Asset URI must be a package-relative forward-slash path: ${uri}`);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
function validateManifest(manifest, packageDir, options = {}) {
|
||||
const errors = [];
|
||||
const fail = (message) => errors.push(message);
|
||||
if (!manifest || manifest.schema !== SCHEMA) fail(`schema must be '${SCHEMA}'`);
|
||||
if (typeof manifest?.packageVersion !== "string" || !manifest.packageVersion) fail("packageVersion is required");
|
||||
if (typeof manifest?.areaId !== "string" || !manifest.areaId) fail("areaId is required");
|
||||
const coordinateSystem = manifest?.coordinateSystem || {};
|
||||
if (coordinateSystem.axes !== "ENU" || coordinateSystem.units !== "meters" || coordinateSystem.x !== "east" || coordinateSystem.y !== "north" || coordinateSystem.z !== "up") fail("coordinateSystem must declare ENU meters (east/north/up)");
|
||||
const placement = manifest?.placement || {};
|
||||
for (const key of ["longitude", "latitude", "height", "headingCorrectionDegrees"]) if (!Number.isFinite(placement[key])) fail(`placement.${key} must be finite`);
|
||||
if (Number.isFinite(placement.longitude) && (placement.longitude < -180 || placement.longitude > 180)) fail("placement.longitude is out of range");
|
||||
if (Number.isFinite(placement.latitude) && (placement.latitude < -90 || placement.latitude > 90)) fail("placement.latitude is out of range");
|
||||
const bounds = manifest?.bounds || {};
|
||||
for (const key of ["minLon", "minLat", "maxLon", "maxLat"]) if (!Number.isFinite(bounds[key])) fail(`bounds.${key} must be finite`);
|
||||
if (Number.isFinite(bounds.minLon) && Number.isFinite(bounds.maxLon) && bounds.minLon > bounds.maxLon) fail("bounds longitude order is invalid");
|
||||
if (Number.isFinite(bounds.minLat) && Number.isFinite(bounds.maxLat) && bounds.minLat > bounds.maxLat) fail("bounds latitude order is invalid");
|
||||
if (!Array.isArray(manifest?.assets) || !manifest.assets.length) fail("assets must be a non-empty array");
|
||||
const ids = new Set(); let sceneCount = 0;
|
||||
for (const asset of manifest?.assets || []) {
|
||||
if (!asset || typeof asset.id !== "string" || !asset.id) { fail("asset id is required"); continue; }
|
||||
if (ids.has(asset.id)) fail(`duplicate asset id '${asset.id}'`); ids.add(asset.id);
|
||||
if (!ROLES.has(asset.role)) fail(`asset '${asset.id}' has invalid role`);
|
||||
if (!CATEGORIES.has(asset.category)) fail(`asset '${asset.id}' has invalid category`);
|
||||
if (typeof asset.defaultLoad !== "boolean") fail(`asset '${asset.id}' defaultLoad must be boolean`);
|
||||
try { relativeUri(asset.uri); } catch (error) { fail(error.message); continue; }
|
||||
if (asset.role === "scene") {
|
||||
sceneCount += 1;
|
||||
if (asset.category !== "scene" || !asset.defaultLoad) fail("scene asset must be category scene and defaultLoad true");
|
||||
} else if (asset.category === "scene" || asset.defaultLoad) fail("layer assets must use a semantic category and defaultLoad false");
|
||||
if (packageDir && options.requireFiles !== false) {
|
||||
const resolved = path.resolve(packageDir, asset.uri);
|
||||
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`asset '${asset.id}' is missing from package: ${asset.uri}`);
|
||||
}
|
||||
}
|
||||
if (manifest.runtime !== undefined && !Array.isArray(manifest.runtime)) fail("runtime must be an array");
|
||||
for (const runtime of manifest?.runtime || []) {
|
||||
if (!runtime || typeof runtime.id !== "string" || !runtime.id) { fail("runtime asset id is required"); continue; }
|
||||
if (ids.has(runtime.id)) fail(`duplicate asset id '${runtime.id}'`); ids.add(runtime.id);
|
||||
if (typeof runtime.type !== "string" || !runtime.type) fail(`runtime '${runtime.id}' type is required`);
|
||||
try { relativeUri(runtime.uri); } catch (error) { fail(error.message); continue; }
|
||||
if (packageDir && options.requireFiles !== false) {
|
||||
const resolved = path.resolve(packageDir, runtime.uri);
|
||||
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`runtime '${runtime.id}' is missing from package: ${runtime.uri}`);
|
||||
}
|
||||
}
|
||||
if (sceneCount !== 1) fail("exactly one scene asset is required");
|
||||
if (errors.length) throw new Error(`Invalid asset package manifest: ${errors.join("; ")}`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function addIntegrity(manifest, packageDir) {
|
||||
for (const asset of manifest.assets) {
|
||||
const file = path.resolve(packageDir, asset.uri);
|
||||
const buffer = fs.readFileSync(file);
|
||||
asset.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
|
||||
}
|
||||
for (const runtime of manifest.runtime || []) {
|
||||
const file = path.resolve(packageDir, runtime.uri);
|
||||
const buffer = fs.readFileSync(file);
|
||||
runtime.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
module.exports = { SCHEMA, CATEGORIES, ROLES, relativeUri, validateManifest, addIntegrity };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
67
scripts/road-workbench.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||
const { compileArea, parseArgs } = require("./compile-native-roads");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||
if (args.noCompile !== "true") compileArea(configPath);
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const port = Number(args.port || 8787);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
|
||||
const server = http.createServer((request, response) => handle(request, response, area, configPath));
|
||||
server.on("error", (error) => {
|
||||
console.error(`Road Workbench failed to listen: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`));
|
||||
}
|
||||
|
||||
function handle(request, response, area, configPath) {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
|
||||
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
|
||||
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||
const compiled = readCompiled(area);
|
||||
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
|
||||
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
|
||||
sendJson(response, 200, { ok: true, overrides });
|
||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
|
||||
compileArea(configPath);
|
||||
sendJson(response, 200, state(area));
|
||||
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
|
||||
sendJson(response, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
function state(area) {
|
||||
const nativeDir = area.outputs.nativeRoadDir;
|
||||
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
}
|
||||
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
|
||||
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
|
||||
function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); }
|
||||
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
|
||||
function sendVendorFile(response, pathname) {
|
||||
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
|
||||
if (!match) return sendJson(response, 404, { error: "Not found" });
|
||||
const root = path.join(repoRoot, "node_modules", match[1]);
|
||||
const file = path.resolve(root, match[2]);
|
||||
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" });
|
||||
return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
|
||||
}
|
||||
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
|
||||
if (require.main === module) main();
|
||||
@@ -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");
|
||||
|
||||
12
scripts/test-build-stages.js
Normal file
12
scripts/test-build-stages.js
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const { resolveStages, canonicalStages } = require("./lib/build-stages");
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]);
|
||||
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
|
||||
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");
|
||||
assert.match(buildAreaSource, /"--debug-gpu-force-workarounds"/);
|
||||
console.log("Build stage tests passed.");
|
||||
113
scripts/test-native-road.js
Normal file
113
scripts/test-native-road.js
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
|
||||
const { compileArea } = require("./compile-native-roads");
|
||||
const { checkArea } = require("./check-native-roads");
|
||||
|
||||
const osm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
|
||||
const empty = { schema: "native-road-overrides/v1", overrides: [] };
|
||||
const initial = compileRoadModel(osm, empty);
|
||||
assert.equal(initial.roads.length, 3);
|
||||
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
|
||||
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
|
||||
const model = compileRoadModel(osm, overrides);
|
||||
const edited = model.roads.find((road) => road.id === target.id);
|
||||
assert.equal(edited.widthMeters, 9);
|
||||
assert.equal(edited.provenance.widthMeters, "override:road-width");
|
||||
assert.equal(edited.sidewalkLeft, false);
|
||||
const geometry = compileGeometry(model);
|
||||
assert.equal(geometry.roadSurface.features.length, 2);
|
||||
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
assert.equal(geometry.sidewalkSurface.features.length, 2);
|
||||
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
|
||||
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
|
||||
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
|
||||
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
|
||||
assert.ok(geometry.connectors.features.length > 0);
|
||||
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
|
||||
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
|
||||
assert.ok(geometry.movements.length >= geometry.connectors.features.length);
|
||||
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
|
||||
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
|
||||
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
|
||||
const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><node id="5" lon="114.001" lat="29.999"/><way id="40"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="41"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="42"><nd ref="5"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="43"><nd ref="2"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const crossCenter = [114.001, 30];
|
||||
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
|
||||
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(crossGeometry.turnArrows.features.length, 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.length > 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
|
||||
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
|
||||
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
|
||||
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
|
||||
assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4);
|
||||
const sharedInteriorNodeOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
|
||||
assert.equal(sharedInteriorModel.roads.length, 6);
|
||||
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/1:forward"));
|
||||
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/2:forward"));
|
||||
const sharedInteriorGeometry = compileGeometry(sharedInteriorModel);
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
|
||||
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
|
||||
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
|
||||
const connection = initial.connections[0];
|
||||
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
|
||||
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
|
||||
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
|
||||
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
|
||||
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
|
||||
assert.ok(compileGeometry(compileRoadModel(osm, connectionOverrides)).connectors.features.length < geometry.connectors.features.length);
|
||||
const disconnectedOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.00105" lat="30"/><node id="4" lon="114.002" lat="30"/><way id="20"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way><way id="21"><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
|
||||
const disconnected = compileRoadModel(disconnectedOsm, empty);
|
||||
const from = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/20:forward" && endpoint.side === "end");
|
||||
const to = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/21:forward" && endpoint.side === "start");
|
||||
const manualOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "manual", kind: "junction-connection", fromEndpointId: from.id, toEndpointId: to.id, enabled: true }] }, disconnected);
|
||||
assert.ok(compileRoadModel(disconnectedOsm, manualOverrides).connections.some((item) => item.fromEndpointId === from.id && item.toEndpointId === to.id));
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "same-way", kind: "junction-connection", fromEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:forward" && endpoint.side === "end").id, toEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:backward" && endpoint.side === "start").id, enabled: true }] }, initial), /manual junction connection/);
|
||||
const turnOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="30"><nd ref="1"/><nd ref="2"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/><tag k="turn:lanes" v="left|through|right"/></way><way id="31"><nd ref="2"/><nd ref="3"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/></way></osm>`;
|
||||
const turnModel = compileRoadModel(turnOsm, empty);
|
||||
const turnGeometry = compileGeometry(turnModel);
|
||||
assert.equal(turnGeometry.sidewalkSurface.features.length, 0);
|
||||
assert.ok(turnGeometry.directionArrows.features.length > 0);
|
||||
assert.ok(turnGeometry.turnArrows.features.length > 0);
|
||||
assert.ok(turnGeometry.turnArrows.features.every((feature) => feature.properties.provenance === "native-road-turn-arrow/v1"));
|
||||
const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel);
|
||||
assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left"));
|
||||
assert.equal(turnGeometry.connectors.features.length, 1);
|
||||
assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:way\/30:forward:1$/);
|
||||
assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/);
|
||||
const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel);
|
||||
assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0);
|
||||
assert.equal(compileGeometry(turnModel, laneOverrides).movements.length, 0);
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
|
||||
const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-"));
|
||||
try {
|
||||
const input = path.join(freshArea, "input.osm");
|
||||
const outputRoot = path.join(freshArea, "outputs");
|
||||
const config = path.join(freshArea, "area.json");
|
||||
fs.writeFileSync(input, osm);
|
||||
fs.writeFileSync(config, JSON.stringify({ id: "fresh", input, outputRoot }));
|
||||
const compiledArea = compileArea(config);
|
||||
assert.equal(compiledArea.result.areaId, "fresh");
|
||||
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
|
||||
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
|
||||
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
|
||||
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
|
||||
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
|
||||
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
|
||||
assert.equal(checkArea(config).ok, true);
|
||||
} finally {
|
||||
fs.rmSync(freshArea, { recursive: true, force: true });
|
||||
}
|
||||
console.log("native road tests passed");
|
||||
25
scripts/test-package-contract.js
Normal file
25
scripts/test-package-contract.js
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEMA, validateManifest } = require("./lib/package-contract");
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-package-"));
|
||||
fs.mkdirSync(path.join(dir, "models"));
|
||||
fs.writeFileSync(path.join(dir, "models", "area.glb"), "glb");
|
||||
fs.writeFileSync(path.join(dir, "runtime.json"), "{}");
|
||||
const valid = { schema: SCHEMA, packageVersion: "1.0.0", areaId: "area", coordinateSystem: { axes: "ENU", units: "meters", x: "east", y: "north", z: "up" }, placement: { longitude: 120, latitude: 30, height: 0, headingCorrectionDegrees: -90 }, bounds: { minLon: 119, minLat: 29, maxLon: 121, maxLat: 31 }, assets: [{ id: "main", role: "scene", category: "scene", uri: "models/area.glb", defaultLoad: true }], runtime: [{ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime.json" }] };
|
||||
assert.doesNotThrow(() => validateManifest(valid, dir));
|
||||
for (const change of [
|
||||
(m) => { m.schema = "bad"; }, (m) => { m.assets[0].uri = "../area.glb"; },
|
||||
(m) => { m.assets[0].category = "dynamic"; }, (m) => { m.assets.push({ ...m.assets[0] }); },
|
||||
(m) => { m.placement.longitude = 900; },
|
||||
]) {
|
||||
const manifest = JSON.parse(JSON.stringify(valid)); change(manifest);
|
||||
assert.throws(() => validateManifest(manifest, dir));
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log("Asset package contract tests passed.");
|
||||
10
scripts/test-package-examples.js
Normal file
10
scripts/test-package-examples.js
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
const assert = require("assert");
|
||||
const { resolveAsset, resolveRuntime, placement } = require("../examples/asset-package-resolver");
|
||||
const manifest = { coordinateSystem: { axes: "ENU" }, placement: { longitude: 120, latitude: 30, height: 2, headingCorrectionDegrees: -90 }, assets: [{ id: "main", uri: "models/a.glb" }, { id: "roads", uri: "models/roads.glb" }], runtime: [{ id: "traffic-signals", uri: "runtime/traffic-signals.json" }] };
|
||||
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest), "https://consumer.example/copied/models/a.glb");
|
||||
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest, "roads"), "https://consumer.example/copied/models/roads.glb");
|
||||
assert.equal(resolveRuntime("https://consumer.example/copied/manifest.json", manifest, "traffic-signals"), "https://consumer.example/copied/runtime/traffic-signals.json");
|
||||
assert.equal(placement(manifest).coordinateSystem.axes, "ENU");
|
||||
console.log("Asset package example resolver tests passed.");
|
||||
@@ -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/);
|
||||
|
||||
31
scripts/test-road-workbench.js
Normal file
31
scripts/test-road-workbench.js
Normal 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");
|
||||
@@ -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");
|
||||
|
||||
1
scripts/workbench/app.css
Normal file
1
scripts/workbench/app.css
Normal 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
198
scripts/workbench/app.js
Normal 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));
|
||||
4
scripts/workbench/index.html
Normal file
4
scripts/workbench/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user