feat: make native road pipeline the default

This commit is contained in:
2026-08-18 14:15:08 +08:00
parent e9ab1f03eb
commit 9e0e25aadf
16 changed files with 134 additions and 82 deletions

View File

@@ -8,14 +8,14 @@
## 两层配置 ## 两层配置
用户只写第一层,第二层是机器生成的中间产物: 用户只写第一层legacy QGIS 链路需要时才生成第二层中间产物:
``` ```
config/areas/<id>.json ← 你写的 config/areas/<id>.json ← 你写的
│ scripts/lib/area-config.js: normalizeAreaConfig() │ scripts/lib/area-config.js: normalizeAreaConfig()
│ 补默认值 + 推导输出路径 │ 补默认值 + 推导输出路径
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改 <areaDir>/_pipeline/osm2streets-qgis.config.json ← legacy 生成的,不要手改
▼ build-osm2streets-qgis.js / reimport-gpkg.js ▼ build-osm2streets-qgis.js / reimport-gpkg.js
``` ```
@@ -65,7 +65,7 @@ cp config/examples/template.json config/areas/my-area.json
| 字段 | 默认 | 说明 | | 字段 | 默认 | 说明 |
|---|---|---| |---|---|---|
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 | | `intermediates` | `false` | native-only 默认;旧名 `qgis` 仍被接受,显式开启才运行 legacy |
| `blender` | `true` | | | `blender` | `true` | |
| `cesium` | `true` | | | `cesium` | `true` | |
| `compress` | `true` | 压缩 staged GLB供随后发布使用 | | `compress` | `true` | 压缩 staged GLB供随后发布使用 |
@@ -132,7 +132,7 @@ cp config/examples/template.json config/areas/my-area.json
|---|---|---| |---|---|---|
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py``TREE_STYLES``natural``procedural``shapespark` | | `treeStyle` | `"natural"` | 合法值见 `generate_scene.py``TREE_STYLES``natural``procedural``shapespark` |
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | | `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 | | `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 |
### `compress` ### `compress`

View File

@@ -1364,9 +1364,9 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
| 阶段 | 做什么 | 读 | 写 | | 阶段 | 做什么 | 读 | 写 |
|---|---|---|---| |---|---|---|---|
| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/``.gpkg``.qgz``-preview.png` | | `intermediates` |(显式 legacyOSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/``.gpkg``.qgz``-preview.png` |
| `reimport` | GeoPackage → GeoJSON**反向** | `.gpkg` | `osm2streets_web_out/` | | `reimport` | GeoPackage → GeoJSON**反向** | `.gpkg` | `osm2streets_web_out/` |
| `blender` | OSM + GeoJSON → 场景 | `.osm``osm2streets_web_out/` | `.blend``.png` | | `blender` | 默认 OSM → native road compiler → Blender 场景legacy provider 时读取 GeoJSON | `.osm``native-road/`legacy 为 `osm2streets_web_out/` | `.blend``.png` |
| `cesium` | 场景 → static staging GLB + manifest | `.blend` | `_pipeline/package-staging/models/*.glb`、staged manifest动态 GLB 到 `_preview/` | | `cesium` | 场景 → static staging GLB + manifest | `.blend` | `_pipeline/package-staging/models/*.glb`、staged manifest动态 GLB 到 `_preview/` |
| `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest | | `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest |
| `package` | 校验并原子发布静态资产包 | staging manifest 与 models | `package/manifest.json``package/models/*.glb` | | `package` | 校验并原子发布静态资产包 | staging manifest 与 models | `package/manifest.json``package/models/*.glb` |
@@ -1386,12 +1386,11 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
未知阶段名**抛错并列出合法值**`:181`),不静默忽略。 未知阶段名**抛错并列出合法值**`:181`),不静默忽略。
### `all` 不含 `reimport` ### `all` 是 native-only 完整构建
```js ```js
// 'reimport' is deliberately absent from 'all': it is a recovery step for // Legacy intermediates/reimport are deliberately absent from 'all'.
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160 all: ["blender", "cesium", "compress", "package", "preview"],
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
``` ```
`compress``package``preview` 均在 `all` 中,确保完整构建以压缩后的可复用静态 package `compress``package``preview` 均在 `all` 中,确保完整构建以压缩后的可复用静态 package

View File

@@ -30,7 +30,7 @@ config/areas/<id>.json
▼ build-area.js — 阶段调度 ▼ build-area.js — 阶段调度
_pipeline/osm2streets-qgis.config.json (派生配置) _pipeline/osm2streets-qgis.config.json (派生配置)
├─[intermediates]─▶ build-osm2streets-qgis.js ├─[intermediates]─▶ build-osm2streets-qgis.js(显式 legacy/reference
│ osm2streets-js-node 解析 .osm │ osm2streets-js-node 解析 .osm
│ → splitLayers() 拆成九个图层 │ → splitLayers() 拆成九个图层
│ → normalize-lane-arrows.pyQGIS Python │ → normalize-lane-arrows.pyQGIS Python
@@ -45,8 +45,8 @@ config/areas/<id>.json
│ → 重建 scene.geojson + scene_style.json │ → 重建 scene.geojson + scene_style.json
│ → _pipeline/stages/reimport.manifest.json │ → _pipeline/stages/reimport.manifest.json
├─[blender]───────▶ Blender + blender/generate_scene.py ├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py
│ 读 .osm + osm2streets_web_out/ 默认读 .osm + native-road/
│ → <id>.blend + <id>.png │ → <id>.blend + <id>.png
│ → _pipeline/stages/blender.manifest.json │ → _pipeline/stages/blender.manifest.json
@@ -62,7 +62,7 @@ config/areas/<id>.json
│ → package/manifest.json + package/models/*.glb │ → package/manifest.json + package/models/*.glb
│ → _pipeline/stages/package.manifest.json │ → _pipeline/stages/package.manifest.json
└─[preview]───────▶ 生成 <id>-cesium-preview.html └─[preview]───────▶ 生成 <id>-cesium-preview.htmlnative 可无车辆路线)
│ + 拷贝 lib/cesium-preview.{js,css} │ + 拷贝 lib/cesium-preview.{js,css}
+ _preview/ 车辆巡航路线、模型、动态信号 + _preview/ 车辆巡航路线、模型、动态信号
│ → _pipeline/stages/preview.manifest.json │ → _pipeline/stages/preview.manifest.json

View File

@@ -31,7 +31,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
`maneuvers``edgeIds``laneSegments``connectors`。道路区间来自匹配的 Driving lane polygon 中轴。 `maneuvers``edgeIds``laneSegments``connectors`。道路区间来自匹配的 Driving lane polygon 中轴。
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。 - 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
- connector 必须绑定同一个 internal intersection并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。 - connector 必须绑定同一个 internal intersection并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
- preview 必须将 `lane_polygons.geojson``network.json``intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败 - legacy osm2streets preview 必须将 `lane_polygons.geojson``network.json``intersection_surface.geojson` 作为强制输入;native preview 不读取这些文件,路线缺失时保留可用预览并省略车辆巡航
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU - route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon `Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
完全一致,最终画面仍会随离锚点距离产生横向偏移。 完全一致,最终画面仍会随离锚点距离产生横向偏移。

View File

@@ -1,6 +1,8 @@
# OSM Asset Pipeline # OSM Asset Pipeline
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图都是中间资产,用来提供道路几何、调试标线效果,以及给 Blender/Cesium 生成提供输入 原始 QGIS/osm2streets 基线保留在 Git tag `qgis-original-baseline-20260818`,用于回撤和对照;当前分支的默认生产链路是 native-only
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。Native road compiler 是默认生产道路来源osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图仅作为显式 legacy/debug 资产或 Workbench 参考层。
## 目标产物 ## 目标产物
@@ -17,17 +19,19 @@
- `<area-id>-roads.glb``-buildings.glb``-vegetation.glb``-water.glb`Cesium 分类检查用的辅助模型 - `<area-id>-roads.glb``-buildings.glb``-vegetation.glb``-water.glb`Cesium 分类检查用的辅助模型
- `<area-id>.json`Cesium 放置元数据和示例代码 - `<area-id>.json`Cesium 放置元数据和示例代码
- `<area-id>-cesium-preview.html`Cesium 本地预览页 - `<area-id>-cesium-preview.html`Cesium 本地预览页
- `osm2streets_web_out/`osm2streets GeoJSON 中间层 - `native-road/`native road compiler 的道路、路口、标线和信号灯运行时产物
- `osm2streets_web_out/`:可选的 osm2streets/QGIS 参考中间层
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`QGIS 调试资产 - `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`QGIS 调试资产
## 环境 ## 环境
需要: 默认 native-only 构建需要:
- QGIS 与 GDAL 工具链
- Blender - Blender
- Node.js / npm - Node.js / npm
QGIS/GDAL 仅在显式运行 legacy `intermediates`/`reimport` 或 Workbench 参考链路时需要。
macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender
`/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为 `/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为
`/usr``blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。 `/usr``blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。
@@ -66,7 +70,7 @@ 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 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` 导出包 staging`compress` 压缩 staging 主 GLB`package` 校验并原子发布 `package/``preview` 生成仅用于本地验证的 HTML 和 `_preview/` 动态描述符。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON不含在 `all`,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。 默认构建依次由 native road compiler、Blender、Cesium、压缩、package 和 preview 组成,不读取 `osm2streets_web_out/`。native preview 在没有 legacy 路线文件时仍可用,只是不提供可选车辆巡航。`intermediates`/`reimport` 仍可显式运行 legacy QGIS 链路,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB该模式用于单独检查生成结果主 GLB 是压缩后的下游交付资产。 Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB该模式用于单独检查生成结果主 GLB 是压缩后的下游交付资产。
@@ -148,9 +152,9 @@ outputs/<area-id>/_pipeline/stages/compress.manifest.json
造成假 stale。 造成假 stale。
manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要前段记录 OSM / manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要前段记录 OSM /
GeoJSON feature countsBlender 记录 `.blend` / renderCesium/压缩记录 GLB digest native-road 或 legacy GeoJSON feature countsBlender 记录 `.blend` / renderCesium/压缩记录 GLB digest
preview 记录 GLB、metadata、`lane_polygons.geojson``network.json``intersection_surface.geojson` preview 记录 GLB、metadata、provider 对应的道路输入、可选车辆路线和 runtime 文件。legacy
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴, 巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,native preview 可省略巡航,
路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些 路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些
manifest缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests``Warnings` manifest缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests``Warnings`
里标出来。 里标出来。
@@ -201,7 +205,7 @@ cp config/examples/template.json config/areas/my-area.json
"qgisApp": "/Applications/QGIS.app", "qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app", "blenderApp": "/Applications/Blender.app",
"stages": { "stages": {
"intermediates": true, "intermediates": false,
"blender": true, "blender": true,
"cesium": true "cesium": true
}, },
@@ -219,7 +223,8 @@ cp config/examples/template.json config/areas/my-area.json
}, },
"blender": { "blender": {
"treeStyle": "natural", "treeStyle": "natural",
"officeOverrides": "" "officeOverrides": "",
"roadProvider": "native"
}, },
"compress": { "compress": {
"textureSize": 768, "textureSize": 768,
@@ -358,7 +363,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
- 这套流程假设你的手工修改已经保存在 `outputs/<area-id>/<area-id>.gpkg` - 这套流程假设你的手工修改已经保存在 `outputs/<area-id>/<area-id>.gpkg`
- 所有图层先导出到临时目录并校验通过后才写回 `osm2streets_web_out/`;任一图层缺失或导出结果不是合法 FeatureCollection整批都不落盘`ogr2ogr` 遇到不存在的图层会留下 0 字节文件,直接覆盖会静默损坏数据) - 所有图层先导出到临时目录并校验通过后才写回 `osm2streets_web_out/`;任一图层缺失或导出结果不是合法 FeatureCollection整批都不落盘`ogr2ogr` 遇到不存在的图层会留下 0 字节文件,直接覆盖会静默损坏数据)
- `intermediates``reimport` 互斥,同时指定会直接报错:前者用 OSM 重建 `gpkg`,正好会抹掉后者要读回的手工修改 - `intermediates``reimport` 互斥,同时指定会直接报错:前者用 OSM 重建 `gpkg`,正好会抹掉后者要读回的手工修改
- `blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg` - 在显式 legacy provider 下,`blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg`native provider 不读取这些文件
- 如果 Blender 当前环境不稳定,先确认 `geojson` 已完成回导,再单独排查 Blender 本身 - 如果 Blender 当前环境不稳定,先确认 `geojson` 已完成回导,再单独排查 Blender 本身
- 增删图层或调整 `z_index` 只需改 `scripts/lib/scene-layers.js`构建、场景合并、场景样式、QGIS 工程会一并同步 - 增删图层或调整 `z_index` 只需改 `scripts/lib/scene-layers.js`构建、场景合并、场景样式、QGIS 工程会一并同步

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app", "qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app", "blenderApp": "/Applications/Blender.app",
"stages": { "stages": {
"intermediates": true, "intermediates": false,
"blender": true, "blender": true,
"cesium": true "cesium": true
}, },

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app", "qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app", "blenderApp": "/Applications/Blender.app",
"stages": { "stages": {
"intermediates": true, "intermediates": false,
"blender": true, "blender": true,
"cesium": true "cesium": true
}, },
@@ -36,6 +36,7 @@
}, },
"blender": { "blender": {
"treeStyle": "shapespark", "treeStyle": "shapespark",
"officeOverrides": "" "officeOverrides": "",
"roadProvider": "native"
} }
} }

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app", "qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app", "blenderApp": "/Applications/Blender.app",
"stages": { "stages": {
"intermediates": true, "intermediates": false,
"blender": true, "blender": true,
"cesium": true "cesium": true
}, },
@@ -37,7 +37,7 @@
"blender": { "blender": {
"treeStyle": "natural", "treeStyle": "natural",
"officeOverrides": "", "officeOverrides": "",
"roadProvider": "osm2streets" "roadProvider": "native"
}, },
"compress": { "compress": {
"textureSize": 768, "textureSize": 768,

View File

@@ -69,10 +69,10 @@ if (stages.compress) {
compressCesiumGlb(area); compressCesiumGlb(area);
} }
if (stages.package) { if (stages.package) {
publishPackage(area); publishPackage(area, roadProvider);
} }
if (stages.preview) { if (stages.preview) {
writeCesiumPreview(area); writeCesiumPreview(area, roadProvider);
} }
console.log("Done."); console.log("Done.");
@@ -491,13 +491,16 @@ function compressCesiumGlb(area) {
} }
} }
function publishPackage(area) { function publishPackage(area, roadProvider) {
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest"); ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors"); const trafficSignalsSource = roadProvider === "native"
? path.join(area.outputs.nativeRoadDir, "traffic-signals.json")
: area.outputs.trafficSignals;
ensureFile(trafficSignalsSource, "Traffic signal anchors");
const started = Date.now(); const started = Date.now();
const manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8")); const manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8"));
fs.mkdirSync(area.outputs.packageStagingRuntimeDir, { recursive: true }); fs.mkdirSync(area.outputs.packageStagingRuntimeDir, { recursive: true });
fs.copyFileSync(area.outputs.trafficSignals, area.outputs.packageStagingTrafficSignals); fs.copyFileSync(trafficSignalsSource, area.outputs.packageStagingTrafficSignals);
manifest.runtime = Array.isArray(manifest.runtime) ? manifest.runtime : []; manifest.runtime = Array.isArray(manifest.runtime) ? manifest.runtime : [];
if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) { if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) {
manifest.runtime.push({ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime/traffic-signals.json" }); manifest.runtime.push({ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime/traffic-signals.json" });
@@ -551,29 +554,44 @@ function runCommand(command, commandArgs, stage) {
} }
} }
function writeCesiumPreview(area) { function writeCesiumPreview(area, roadProvider) {
ensureFile(area.outputs.packageManifest, "Published asset package manifest"); ensureFile(area.outputs.packageManifest, "Published asset package manifest");
ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors"); ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors");
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson"); let vehicleRoute = null;
const network = path.join(area.outputs.geojsonDir, "network.json"); const previewInputs = {
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson"); config: fileRecord(configPath),
ensureFile(lanePolygons, "Driving lane polygons"); osm: fileRecord(area.input),
ensureFile(network, "osm2streets network"); previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
ensureFile(intersectionSurface, "Intersection surfaces"); previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。 };
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface); if (roadProvider === "osm2streets") {
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const network = path.join(area.outputs.geojsonDir, "network.json");
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
ensureFile(lanePolygons, "Driving lane polygons");
ensureFile(network, "osm2streets network");
ensureFile(intersectionSurface, "Intersection surfaces");
vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
Object.assign(previewInputs, {
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
});
} else {
Object.assign(previewInputs, nativeRoadRecords(area));
}
const htmlPath = area.outputs.cesiumPreview; const htmlPath = area.outputs.cesiumPreview;
const started = Date.now(); const started = Date.now();
const startedAt = new Date(started).toISOString(); const startedAt = new Date(started).toISOString();
fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
writeVehicleRoute(area, vehicleRoute); if (vehicleRoute) writeVehicleRoute(area, vehicleRoute);
const vehicleModelNames = writeVehicleModel(area); const vehicleModelNames = writeVehicleModel(area);
writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = "package/manifest.json"; const glbName = "package/manifest.json";
const metadataName = "package/manifest.json"; const metadataName = "package/manifest.json";
const routeName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute); const routeName = vehicleRoute ? previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute) : null;
const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel); 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: [] }; const descriptor = { routeName, 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.mkdirSync(area.outputs.previewDir, { recursive: true });
fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`); 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")); fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json"));
@@ -587,19 +605,13 @@ function writeCesiumPreview(area) {
finishedAt: new Date(finished).toISOString(), finishedAt: new Date(finished).toISOString(),
durationMs: finished - started, durationMs: finished - started,
inputs: { inputs: {
config: fileRecord(configPath), ...previewInputs,
osm: fileRecord(area.input),
glb: fileRecord(area.outputs.packagePrimaryGlb), glb: fileRecord(area.outputs.packagePrimaryGlb),
metadata: fileRecord(area.outputs.packageManifest), metadata: fileRecord(area.outputs.packageManifest),
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
}, },
outputs: { outputs: {
cesiumPreview: fileRecord(area.outputs.cesiumPreview), cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute), vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel), vehicleModel: fileRecord(area.outputs.vehicleModel),
trafficSignals: fileRecord(area.outputs.packageTrafficSignals), trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
}, },

View File

@@ -85,7 +85,9 @@ function normalizeAreaConfig(raw, options = {}) {
qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"), qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"),
blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"), blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"),
stages: { stages: {
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true, // Native road compilation is the default. Legacy QGIS/osm2streets
// intermediates remain explicitly selectable for reference/debug work.
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? false,
blender: raw.stages?.blender ?? true, blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true, cesium: raw.stages?.cesium ?? true,
reimport: false, reimport: false,
@@ -121,7 +123,7 @@ function normalizeAreaConfig(raw, options = {}) {
blender: { blender: {
treeStyle: raw.blender?.treeStyle || "natural", treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "", officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
roadProvider: roadProviderOption(raw.blender?.roadProvider), roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"),
}, },
compress, compress,
budget, budget,

View File

@@ -323,13 +323,17 @@ function addEndpoint(map, ref) {
} }
function artifactStatus(area) { function artifactStatus(area) {
const native = area.blender.roadProvider === "native";
const entries = [ const entries = [
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"], ...(native ? [] : [
["GeoPackage", area.outputs.gpkg, true, "file"], ["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
["QGIS project", area.outputs.qgisProject, true, "file"], ["GeoPackage", area.outputs.gpkg, true, "file"],
["QGIS preview", area.outputs.qgisPreview, true, "file"], ["QGIS project", area.outputs.qgisProject, true, "file"],
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"], ["QGIS preview", area.outputs.qgisPreview, true, "file"],
["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"], ["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
]),
["Native road directory", area.outputs.nativeRoadDir, native, "dir"],
["Traffic signal runtime", native ? path.join(area.outputs.nativeRoadDir, "traffic-signals.json") : area.outputs.packageTrafficSignals, true, "file"],
["Blend scene", area.outputs.blend, true, "file"], ["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"], ["Render PNG", area.outputs.render, true, "file"],
["Package manifest", area.outputs.packageManifest, true, "file"], ["Package manifest", area.outputs.packageManifest, true, "file"],
@@ -373,6 +377,7 @@ function metadataSummary(file, warnings) {
} }
function stageManifestStatus(area, configPath = null) { function stageManifestStatus(area, configPath = null) {
const native = area.blender.roadProvider === "native";
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress")); const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
const reimportManifest = stageManifestPath(area, "reimport"); const reimportManifest = stageManifestPath(area, "reimport");
const hasReimportManifest = fs.existsSync(reimportManifest); const hasReimportManifest = fs.existsSync(reimportManifest);
@@ -430,10 +435,12 @@ function stageManifestStatus(area, configPath = null) {
inputs: { inputs: {
...(configPath ? { config: configPath } : {}), ...(configPath ? { config: configPath } : {}),
osm: area.input, osm: area.input,
geojsonDir: area.outputs.geojsonDir, ...(native ? nativeRoadRecords(area) : {
...sceneGeojsonFiles(area), geojsonDir: area.outputs.geojsonDir,
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, ...sceneGeojsonFiles(area),
trafficSignals: area.outputs.packageTrafficSignals, trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.packageTrafficSignals,
}),
}, },
outputs: { outputs: {
blend: area.outputs.blend, blend: area.outputs.blend,
@@ -461,18 +468,20 @@ function stageManifestStatus(area, configPath = null) {
glb: area.outputs.glb, glb: area.outputs.glb,
metadata: area.outputs.metadata, metadata: area.outputs.metadata,
}), }),
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"), ...(native ? nativeRoadRecords(area) : {
network: path.join(area.outputs.geojsonDir, "network.json"), lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.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"), previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"), previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
}, },
outputs: compressionComplete ? { outputs: compressionComplete ? {
vehicleRoute: area.outputs.vehicleRoute, vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
vehicleModel: area.outputs.vehicleModel, vehicleModel: area.outputs.vehicleModel,
} : { } : {
cesiumPreview: area.outputs.cesiumPreview, cesiumPreview: area.outputs.cesiumPreview,
vehicleRoute: area.outputs.vehicleRoute, vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
vehicleModel: area.outputs.vehicleModel, vehicleModel: area.outputs.vehicleModel,
}, },
}, },
@@ -597,6 +606,24 @@ function sceneGeojsonFiles(area) {
return files; return files;
} }
function nativeRoadRecords(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
return {
nativeRoadCompiled: path.join(area.outputs.nativeRoadDir, "compiled.json"),
nativeRoadSignals: path.join(area.outputs.nativeRoadDir, "traffic-signals.json"),
nativeRoadSurface: path.join(root, "road_surface.geojson"),
nativeEdgeLines: path.join(root, "edge_lines.geojson"),
nativeIntersectionSurface: path.join(root, "intersection_surface.geojson"),
nativeSidewalkSurface: path.join(root, "sidewalk_surface.geojson"),
nativeLaneSeparators: path.join(root, "lane_separators.geojson"),
nativeCenterLines: path.join(root, "center_lines.geojson"),
nativeDirectionArrows: path.join(root, "direction_arrows.geojson"),
nativeTurnArrows: path.join(root, "turn_arrows.geojson"),
nativeCrosswalks: path.join(root, "crosswalks.geojson"),
nativeVehicleStopLines: path.join(root, "vehicle_stop_lines.geojson"),
};
}
function collectWarnings(area, osm, artifacts, manifests, glb, metadata) { function collectWarnings(area, osm, artifacts, manifests, glb, metadata) {
const warnings = []; const warnings = [];
if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong."); if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong.");

View File

@@ -124,14 +124,14 @@ function escapeScriptJson(value) {
} }
function previewSummary(area) { function previewSummary(area) {
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8")); const route = fs.existsSync(area.outputs.vehicleRoute) ? JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8")) : null;
return { return {
glbName: "package/manifest.json", glbName: "package/manifest.json",
metadataName: "package/manifest.json", metadataName: "package/manifest.json",
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"), routeName: route ? path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/") : null,
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"), vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json", trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route.segments) ? route.segments.length : null, routeSegments: Array.isArray(route?.segments) ? route.segments.length : null,
}; };
} }

View File

@@ -1,7 +1,7 @@
"use strict"; "use strict";
const STAGES = [ const STAGES = [
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" }, { id: "intermediates", label: "Legacy QGIS intermediates", description: "Explicit legacy osm2streets/QGIS reference build" },
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" }, { 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: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" }, { id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
@@ -11,7 +11,7 @@ const STAGES = [
]; ];
const ALIASES = { const ALIASES = {
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"], all: ["blender", "cesium", "compress", "package", "preview"],
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"], qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"], intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"], cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],

View File

@@ -88,6 +88,7 @@
// The route file is an extra on top of the scene, not a precondition for it. // The route file is an extra on top of the scene, not a precondition for it.
// A missing or unreadable route costs the cruise controls, not the preview. // A missing or unreadable route costs the cruise controls, not the preview.
async function fetchOptionalJson(url) { async function fetchOptionalJson(url) {
if (!url) return null;
try { try {
return await fetchJson(url); return await fetchJson(url);
} catch (error) { } catch (error) {

View File

@@ -17,6 +17,9 @@ assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/); assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/); assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/); assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{[\s\S]*?buildPreviewVehicleRoute/);
assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(area\)\)/);
assert.match(areaBuildSource, /vehicleRoute: optionalFileRecord\(area\.outputs\.vehicleRoute\)/);
const gltf = { const gltf = {
nodes: [ nodes: [
@@ -54,7 +57,8 @@ assert.equal(
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"), 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, budget: { nodes: 800 } }).budget.glbNodes, 800);
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets"); assert.equal(normalizeAreaConfig(base).stages.intermediates, false);
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native");
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native"); assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
assert.throws( assert.throws(
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }), () => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
@@ -78,8 +82,9 @@ assert.equal(
); );
const configPath = path.join(tempDir, "area.json"); const configPath = path.join(tempDir, "area.json");
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`); const legacyBase = { ...base, blender: { roadProvider: "osm2streets" } };
const area = normalizeAreaConfig(base); fs.writeFileSync(configPath, `${JSON.stringify(legacyBase)}\n`);
const area = normalizeAreaConfig(legacyBase);
assert.equal(area.stages.compress, true); assert.equal(area.stages.compress, true);
assert.equal("compressedGlb" in area.outputs, false); assert.equal("compressedGlb" in area.outputs, false);
assert.equal("compressedMetadata" in area.outputs, false); assert.equal("compressedMetadata" in area.outputs, false);

View File

@@ -4,7 +4,7 @@ const assert = require("assert");
const fs = require("fs"); const fs = require("fs");
const { resolveStages, canonicalStages } = require("./lib/build-stages"); const { resolveStages, canonicalStages } = require("./lib/build-stages");
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]); 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({}, ["all"])), ["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.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/); assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8"); const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");