diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index dcb5a72..9d8da4a 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -8,14 +8,14 @@ ## 两层配置 -用户只写第一层,第二层是机器生成的中间产物: +用户只写第一层;legacy QGIS 链路需要时才生成第二层中间产物: ``` config/areas/.json ← 你写的 │ scripts/lib/area-config.js: normalizeAreaConfig() │ 补默认值 + 推导输出路径 ▼ -/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改 +/_pipeline/osm2streets-qgis.config.json ← legacy 生成的,不要手改 │ ▼ 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` | | | `cesium` | `true` | | | `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`) | | `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | -| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 | +| `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 | ### `compress` diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index ec02fb7..3a5ee06 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -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` |(显式 legacy)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` | +| `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/` | | `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest | | `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`),不静默忽略。 -### `all` 不含 `reimport` +### `all` 是 native-only 完整构建 ```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", "compress", "package", "preview"], +// Legacy intermediates/reimport are deliberately absent from 'all'. +all: ["blender", "cesium", "compress", "package", "preview"], ``` `compress`、`package`、`preview` 均在 `all` 中,确保完整构建以压缩后的可复用静态 package diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md index c14afb5..fdff2dc 100644 --- a/.trellis/spec/pipeline/index.md +++ b/.trellis/spec/pipeline/index.md @@ -30,7 +30,7 @@ config/areas/.json ▼ build-area.js — 阶段调度 _pipeline/osm2streets-qgis.config.json (派生配置) │ - ├─[intermediates]─▶ build-osm2streets-qgis.js + ├─[intermediates]─▶ build-osm2streets-qgis.js(显式 legacy/reference) │ osm2streets-js-node 解析 .osm │ → splitLayers() 拆成九个图层 │ → normalize-lane-arrows.py(QGIS Python) @@ -45,8 +45,8 @@ config/areas/.json │ → 重建 scene.geojson + scene_style.json │ → _pipeline/stages/reimport.manifest.json │ - ├─[blender]───────▶ Blender + blender/generate_scene.py - │ 读 .osm + osm2streets_web_out/ + ├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py + │ 默认读 .osm + native-road/ │ → .blend + .png │ → _pipeline/stages/blender.manifest.json │ @@ -62,7 +62,7 @@ config/areas/.json │ → package/manifest.json + package/models/*.glb │ → _pipeline/stages/package.manifest.json │ - └─[preview]───────▶ 生成 -cesium-preview.html + └─[preview]───────▶ 生成 -cesium-preview.html(native 可无车辆路线) │ + 拷贝 lib/cesium-preview.{js,css} + _preview/ 车辆巡航路线、模型、动态信号 │ → _pipeline/stages/preview.manifest.json diff --git a/.trellis/spec/preview/vehicle-routes.md b/.trellis/spec/preview/vehicle-routes.md index 8dda1a8..d5eb522 100644 --- a/.trellis/spec/preview/vehicle-routes.md +++ b/.trellis/spec/preview/vehicle-routes.md @@ -31,7 +31,7 @@ classifyConnection(incomingEdge, outgoingEdge) => `maneuvers`、`edgeIds`、`laneSegments` 与 `connectors`。道路区间来自匹配的 Driving lane polygon 中轴。 - 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。 - connector 必须绑定同一个 internal intersection,并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。 -- preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。 +- legacy osm2streets preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;native preview 不读取这些文件,路线缺失时保留可用预览并省略车辆巡航。 - route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU `Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon 完全一致,最终画面仍会随离锚点距离产生横向偏移。 diff --git a/README.md b/README.md index 138ed4f..214a6d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 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 @@ - `-roads.glb`、`-buildings.glb`、`-vegetation.glb`、`-water.glb`:Cesium 分类检查用的辅助模型 - `.json`:Cesium 放置元数据和示例代码 - `-cesium-preview.html`:Cesium 本地预览页 -- `osm2streets_web_out/`:osm2streets GeoJSON 中间层 +- `native-road/`:native road compiler 的道路、路口、标线和信号灯运行时产物 +- `osm2streets_web_out/`:可选的 osm2streets/QGIS 参考中间层 - `.gpkg` / `.qgz` / `-preview.png`:QGIS 调试资产 ## 环境 -需要: +默认 native-only 构建需要: -- QGIS 与 GDAL 工具链 - Blender - Node.js / npm +QGIS/GDAL 仅在显式运行 legacy `intermediates`/`reimport` 或 Workbench 参考链路时需要。 + macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender `/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为 `/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 ``` -`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 是压缩后的下游交付资产。 @@ -148,9 +152,9 @@ outputs//_pipeline/stages/compress.manifest.json 造成假 stale。 manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要:前段记录 OSM / -GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest, -preview 记录 GLB、metadata、`lane_polygons.geojson`、`network.json`、`intersection_surface.geojson`、 -车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴, +native-road 或 legacy GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest, +preview 记录 GLB、metadata、provider 对应的道路输入、可选车辆路线和 runtime 文件。legacy +巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,native preview 可省略巡航, 路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些 manifest;缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests` 和 `Warnings` 里标出来。 @@ -201,7 +205,7 @@ cp config/examples/template.json config/areas/my-area.json "qgisApp": "/Applications/QGIS.app", "blenderApp": "/Applications/Blender.app", "stages": { - "intermediates": true, + "intermediates": false, "blender": true, "cesium": true }, @@ -219,7 +223,8 @@ cp config/examples/template.json config/areas/my-area.json }, "blender": { "treeStyle": "natural", - "officeOverrides": "" + "officeOverrides": "", + "roadProvider": "native" }, "compress": { "textureSize": 768, @@ -358,7 +363,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json - 这套流程假设你的手工修改已经保存在 `outputs//.gpkg` 中 - 所有图层先导出到临时目录并校验通过后才写回 `osm2streets_web_out/`;任一图层缺失或导出结果不是合法 FeatureCollection,整批都不落盘(`ogr2ogr` 遇到不存在的图层会留下 0 字节文件,直接覆盖会静默损坏数据) - `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 本身 - 增删图层或调整 `z_index` 只需改 `scripts/lib/scene-layers.js`,构建、场景合并、场景样式、QGIS 工程会一并同步 diff --git a/config/areas/hanyang-block.json b/config/areas/hanyang-block.json index a944842..86f57a9 100644 --- a/config/areas/hanyang-block.json +++ b/config/areas/hanyang-block.json @@ -5,7 +5,7 @@ "qgisApp": "/Applications/QGIS.app", "blenderApp": "/Applications/Blender.app", "stages": { - "intermediates": true, + "intermediates": false, "blender": true, "cesium": true }, diff --git a/config/areas/nantaizi-lake-innovation-valley.json b/config/areas/nantaizi-lake-innovation-valley.json index e9e6aac..959caed 100644 --- a/config/areas/nantaizi-lake-innovation-valley.json +++ b/config/areas/nantaizi-lake-innovation-valley.json @@ -5,7 +5,7 @@ "qgisApp": "/Applications/QGIS.app", "blenderApp": "/Applications/Blender.app", "stages": { - "intermediates": true, + "intermediates": false, "blender": true, "cesium": true }, @@ -36,6 +36,7 @@ }, "blender": { "treeStyle": "shapespark", - "officeOverrides": "" + "officeOverrides": "", + "roadProvider": "native" } } diff --git a/config/examples/template.json b/config/examples/template.json index 9ca7207..45cff31 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -5,7 +5,7 @@ "qgisApp": "/Applications/QGIS.app", "blenderApp": "/Applications/Blender.app", "stages": { - "intermediates": true, + "intermediates": false, "blender": true, "cesium": true }, @@ -37,7 +37,7 @@ "blender": { "treeStyle": "natural", "officeOverrides": "", - "roadProvider": "osm2streets" + "roadProvider": "native" }, "compress": { "textureSize": 768, diff --git a/scripts/build-area.js b/scripts/build-area.js index ef58ada..3dde72d 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -69,10 +69,10 @@ if (stages.compress) { compressCesiumGlb(area); } if (stages.package) { - publishPackage(area); + publishPackage(area, roadProvider); } if (stages.preview) { - writeCesiumPreview(area); + writeCesiumPreview(area, roadProvider); } 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.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 manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8")); 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 : []; if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) { 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.packageTrafficSignals, "Published traffic signal anchors"); - const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson"); - const network = path.join(area.outputs.geojsonDir, "network.json"); - const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson"); - ensureFile(lanePolygons, "Driving lane polygons"); - ensureFile(network, "osm2streets network"); - ensureFile(intersectionSurface, "Intersection surfaces"); - // 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。 - const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface); + let vehicleRoute = null; + const previewInputs = { + config: fileRecord(configPath), + osm: fileRecord(area.input), + previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")), + previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")), + }; + 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 started = Date.now(); const startedAt = new Date(started).toISOString(); fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); - writeVehicleRoute(area, vehicleRoute); + if (vehicleRoute) writeVehicleRoute(area, vehicleRoute); const vehicleModelNames = writeVehicleModel(area); writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); const glbName = "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 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.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")); @@ -587,19 +605,13 @@ function writeCesiumPreview(area) { finishedAt: new Date(finished).toISOString(), durationMs: finished - started, inputs: { - config: fileRecord(configPath), - osm: fileRecord(area.input), + ...previewInputs, glb: fileRecord(area.outputs.packagePrimaryGlb), metadata: fileRecord(area.outputs.packageManifest), - lanePolygons: fileRecord(lanePolygons), - network: fileRecord(network), - intersectionSurface: fileRecord(intersectionSurface), - previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")), - previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")), }, outputs: { cesiumPreview: fileRecord(area.outputs.cesiumPreview), - vehicleRoute: fileRecord(area.outputs.vehicleRoute), + vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute), vehicleModel: fileRecord(area.outputs.vehicleModel), trafficSignals: fileRecord(area.outputs.packageTrafficSignals), }, diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 004c996..1859abf 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -85,7 +85,9 @@ function normalizeAreaConfig(raw, options = {}) { qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"), blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"), 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, cesium: raw.stages?.cesium ?? true, reimport: false, @@ -121,7 +123,7 @@ function normalizeAreaConfig(raw, options = {}) { blender: { treeStyle: raw.blender?.treeStyle || "natural", officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "", - roadProvider: roadProviderOption(raw.blender?.roadProvider), + roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"), }, compress, budget, diff --git a/scripts/lib/area-diagnostics.js b/scripts/lib/area-diagnostics.js index 00faa13..da61eaf 100644 --- a/scripts/lib/area-diagnostics.js +++ b/scripts/lib/area-diagnostics.js @@ -323,13 +323,17 @@ function addEndpoint(map, ref) { } function artifactStatus(area) { + const native = area.blender.roadProvider === "native"; const entries = [ - ["GeoJSON dir", area.outputs.geojsonDir, true, "dir"], - ["GeoPackage", area.outputs.gpkg, true, "file"], - ["QGIS project", area.outputs.qgisProject, true, "file"], - ["QGIS preview", area.outputs.qgisPreview, true, "file"], - ["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"], - ["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"], + ...(native ? [] : [ + ["GeoJSON dir", area.outputs.geojsonDir, true, "dir"], + ["GeoPackage", area.outputs.gpkg, true, "file"], + ["QGIS project", area.outputs.qgisProject, true, "file"], + ["QGIS preview", area.outputs.qgisPreview, true, "file"], + ["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"], + ]), + ["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"], ["Render PNG", area.outputs.render, true, "file"], ["Package manifest", area.outputs.packageManifest, true, "file"], @@ -373,6 +377,7 @@ function metadataSummary(file, warnings) { } function stageManifestStatus(area, configPath = null) { + const native = area.blender.roadProvider === "native"; const compressionComplete = fs.existsSync(stageManifestPath(area, "compress")); const reimportManifest = stageManifestPath(area, "reimport"); const hasReimportManifest = fs.existsSync(reimportManifest); @@ -430,10 +435,12 @@ function stageManifestStatus(area, configPath = null) { inputs: { ...(configPath ? { config: configPath } : {}), osm: area.input, - geojsonDir: area.outputs.geojsonDir, - ...sceneGeojsonFiles(area), - trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, - trafficSignals: area.outputs.packageTrafficSignals, + ...(native ? nativeRoadRecords(area) : { + geojsonDir: area.outputs.geojsonDir, + ...sceneGeojsonFiles(area), + trafficSignalAssemblies: area.outputs.trafficSignalAssemblies, + trafficSignals: area.outputs.packageTrafficSignals, + }), }, outputs: { blend: area.outputs.blend, @@ -461,18 +468,20 @@ function stageManifestStatus(area, configPath = null) { 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"), + ...(native ? nativeRoadRecords(area) : { + 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: compressionComplete ? { - vehicleRoute: area.outputs.vehicleRoute, + vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute, vehicleModel: area.outputs.vehicleModel, } : { cesiumPreview: area.outputs.cesiumPreview, - vehicleRoute: area.outputs.vehicleRoute, + vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute, vehicleModel: area.outputs.vehicleModel, }, }, @@ -597,6 +606,24 @@ function sceneGeojsonFiles(area) { 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) { const warnings = []; if (!osm.bounds) warnings.push("OSM has no valid ; scene extent may be wrong."); diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 4522338..28bcce8 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -124,14 +124,14 @@ function escapeScriptJson(value) { } 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 { glbName: "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("/"), trafficSignalsName: "package/runtime/traffic-signals.json", - routeSegments: Array.isArray(route.segments) ? route.segments.length : null, + routeSegments: Array.isArray(route?.segments) ? route.segments.length : null, }; } diff --git a/scripts/lib/build-stages.js b/scripts/lib/build-stages.js index 3fcb40c..72aba4e 100644 --- a/scripts/lib/build-stages.js +++ b/scripts/lib/build-stages.js @@ -1,7 +1,7 @@ "use strict"; 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: "blender", label: "Blender scene", description: "Generate the editable scene and render" }, { id: "cesium", label: "Cesium export", description: "Export static package staging assets" }, @@ -11,7 +11,7 @@ const STAGES = [ ]; const ALIASES = { - all: ["intermediates", "blender", "cesium", "compress", "package", "preview"], + all: ["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"], diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index ded3754..f87aafb 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -88,6 +88,7 @@ // 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. async function fetchOptionalJson(url) { + if (!url) return null; try { return await fetchJson(url); } catch (error) { diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index f2e4d21..f494df4 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -17,6 +17,9 @@ 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/); +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 = { nodes: [ @@ -54,7 +57,8 @@ 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).stages.intermediates, false); +assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native"); assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native"); assert.throws( () => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }), @@ -78,8 +82,9 @@ assert.equal( ); const configPath = path.join(tempDir, "area.json"); -fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`); -const area = normalizeAreaConfig(base); +const legacyBase = { ...base, blender: { roadProvider: "osm2streets" } }; +fs.writeFileSync(configPath, `${JSON.stringify(legacyBase)}\n`); +const area = normalizeAreaConfig(legacyBase); assert.equal(area.stages.compress, true); assert.equal("compressedGlb" in area.outputs, false); assert.equal("compressedMetadata" in area.outputs, false); diff --git a/scripts/test-build-stages.js b/scripts/test-build-stages.js index e097466..fb126c2 100644 --- a/scripts/test-build-stages.js +++ b/scripts/test-build-stages.js @@ -4,7 +4,7 @@ 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({}, ["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.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/); const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");