7 Commits

47 changed files with 600 additions and 122 deletions

View File

@@ -47,8 +47,8 @@ config/areas/<id>.json
│ → 重建 scene.geojson + scene_style.json
│ → _pipeline/stages/reimport.manifest.json
├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py
│ 默认读 .osm + native-road/
├─[blender]───────▶ native-road ZIP importer + Blender + blender/generate_scene.py
│ 默认读 .osm + validated ZIP cache/
│ → <id>.blend + <id>.png
│ → _pipeline/stages/blender.manifest.json

View File

@@ -1,16 +1,16 @@
# Native Road Compiler Contract
# Native Road Package Contract
The native-road package is maintained in the private `road-compiler` repository:
`https://git.app.que01.top/que01/road-compiler`
This host consumes the exact git tag recorded in `package.json` and lockfile. The host owns area configuration normalization and writes `RoadCompilerInput`; the compiler owns the `native-road-package/v1` file contract and CLI. Production code must invoke the installed compiler CLI through `scripts/lib/road-compiler-cli.js`, not import compiler source files or read `packages/road-compiler`.
The host consumes a read-only ZIP exported by the private `road-compiler` repository. The host never installs or invokes the compiler. `nativeRoadPackage` is resolved relative to the area config, hashed, validated, and extracted into `_pipeline/native-road-import/<sha256>/` before native consumers start.
The completion marker is `NATIVE_ROAD_COMPILE_DONE <json>`. `build-area.js` validates the marker's count, JSON payload, area id, output path, process status, and signal before starting Blender.
## Scope / Trigger
This contract applies to native-road compilation from `scripts/build-area.js`, `scripts/compile-native-roads.js`, and the road workbench fresh-compile callback.
This contract applies to native-road consumption from `scripts/build-area.js`, diagnostics, parity, Blender, and native preview generation.
## Signatures

View File

@@ -22,11 +22,12 @@
"08-25-rc-p0-contract-baseline",
"08-25-rc-p1-package-boundary",
"08-25-rc-p2-repo-split",
"08-25-rc-p3-render-separation"
"08-25-rc-p3-render-separation",
"08-26-rc-p5-artifact-consumption"
],
"parent": null,
"relatedFiles": [],
"notes": "",
"notes": "P3 已完成。P5 artifact-only parent 已建立P5A ZIP export 已在 road-compiler v0.3.0 完成P5B 宿主 ZIP import 待实施。",
"meta": {
"next_task": "08-25-rc-p4-drawtonomy-ext"
}

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/blender/testing.md","reason":"P3 scene digest parity 是消费迁移的回归门槛。"}
{"file":".trellis/spec/pipeline/native-road-package.md","reason":"验证 ZIP 解压后的 v1.1 contract、areaId 和 source 集合。"}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"验证导入迁移不改变 Blender 产物结构。"}

View File

@@ -0,0 +1,23 @@
# P5 Artifact-Only Road Package Design
P5 replaces the current runtime dependency boundary with a file-only boundary:
```text
road-compiler Web editor
-> deterministic root-flat native-road-package/v1.1 ZIP
-> osmWorkflow config nativeRoadPackage
-> private verified extraction cache
-> Blender / Cesium / preview
```
The road-compiler repository owns export and all editing/compiler internals. osmWorkflow owns ZIP safety,
contract validation, Blender materials and downstream scene construction. Neither repository reads the
other's source, configuration, overrides or runtime implementation.
The ZIP is immutable input and the host's `_pipeline/native-road-import/<sha256>` is disposable cache.
The host rejects unsafe archive topology before writing files, then validates v1.1 `manifest.json` and
area identity before starting Blender. Stage manifests record the ZIP hash and resolved package records.
P5A publishes a fixture/exporter first. P5B uses that fixture to remove compiler CLI installation and
invocation from the host. The migration is intentionally a contract-preserving transport change: rendered
geometry and material behavior must remain at the P3 baseline.

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/native-road-package.md","reason":"宿主与编译器之间的现有文件契约。"}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"ZIP 导入必须在 Blender 副作用前完成安全校验。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"导出、Node 导入、Blender 和 preview 的跨层数据流。"}

View File

@@ -0,0 +1,14 @@
# P5 Integration Plan
1. Complete P5A in `road-compiler`: deterministic Web/CLI ZIP export plus supported-area fixtures and a
tagged compiler release.
2. Complete P5B in `osmWorkflow`: safe importer, config migration, CLI/dependency removal and downstream
descriptor migration.
3. Run the shared end-to-end gate on `fengshu-er-road` and `nantaizi-lake-innovation-valley`:
import ZIP -> Blender -> Cesium -> package -> preview -> area checks.
4. Compare Blender scene digests to P3 control; run negative ZIP tests and legacy provider tests.
5. Record contract docs in both repositories, commit each repository independently, then archive both
children and this integration parent.
Integration rollback: restore the host's previous tagged compiler dependency and compile adapter. ZIP export
is additive and its generated files remain valid standalone artifacts.

View File

@@ -0,0 +1,95 @@
# 道路包导入与纯消费管线
父任务:`.trellis/tasks/08-25-road-compiler-extraction/`
本任务是集成 parent导出与导入分别在 road-compiler 和宿主仓库实现,只有两个子任务的
端到端 ZIP 契约都通过后才完成。
## Goal
让 road-compiler Web 导出的 `native-road-package/v1.1` 成为 Blender 管线的唯一
native-road 输入。宿主只校验并消费完整道路包,不再安装、调用或理解道路编译器。
用户价值:人工在 road-compiler Web 编辑后,导出道路包即可稳定交给 Blender / Cesium
构建;编辑成果不再通过发布新的编译器代码版本传播。
## Confirmed Facts
- P3 已使 Blender 通过 `manifest.json` 消费 `layers/*.geojson`,并拒绝无效 manifest、
source/file 不一致和未知宿主材质槽。
- 宿主当前仍通过 `scripts/build-area.js` 调用 `scripts/lib/road-compiler-cli.js`;后者安装并
运行 `@osm-asset/road-compiler`,将 `RoadCompilerInput` 写入 `_pipeline/`
- native Blender 构建当前强制使用 `outputs/<area>/native-road/`,并在每次构建前重新编译;
宿主还持有 `nativeRoad.edgeLines``junctionTemplates`、overrides 和 compiler runtime
路径等编译器内部概念。
- `osm2streets` 是显式 legacy/debug provider应保持现有行为。
- 有效验证区域只有 `fengshu-er-road``nantaizi-lake-innovation-valley`
`hanyang-block` 不参与。
## Requirements
### R1 纯产物边界
- native provider 只接收一个完整 `native-road-package/v1.1` 目录。
- 宿主不得依赖 `@osm-asset/road-compiler`,不得调用 compiler CLI不得写
`RoadCompilerInput`,不得读取或解释 compiler overrides / junction templates。
- 宿主以 manifest 作为道路图层事实源Blender 不增加 compiler 专用图层表。
### R2 ZIP 导入配置和校验
- 每个 native 区域配置一个道路包 ZIP 路径;路径相对于区域配置文件解析并可指向工作区外的
road-compiler Web 导出文件。
- ZIP 根目录必须直接包含 `manifest.json` 和契约定义的文件;不得接受多余顶层目录、绝对路径、
`..` 路径或符号链接。
- 在启动 Blender 前,宿主将 ZIP 安全解压到其私有 pipeline staging 目录,校验 manifest
契约版本、`areaId` 和当前区域一致,并记录 ZIP hash 和展开文件记录到 stage manifest。
- ZIP 永远只读:宿主不复制、覆盖或修改 road-compiler Web 的导出文件;成功或失败后仅清理
自己的临时解压目录。
- Blender 继续校验 manifest 的图层、source、semantic 角色、材质槽和 splitBy 规则。
### R3 后续阶段
- Cesium、package、preview 和区域诊断从导入道路包读取原生车道中心线、signals 和
其他既有 native-road 输入;不重新编译或复制道路包。
- build stage manifest 将道路包作为外部输入记录,而非宿主输出。
### R4 清理和迁移
- 删除宿主对 compiler npm 包、CLI 封装和编译脚本的生产依赖。
- 删除或替换只测试宿主编译行为的测试;保留 package-contract、Blender 和下游消费测试。
- 更新示例配置、README、pipeline spec 和道路包契约,明确 Web export -> host consume
的单向关系。
## Acceptance Criteria
- [ ] AC1 `package.json` / lockfile 和生产脚本不再引用 `@osm-asset/road-compiler` 或其 CLI。
- [ ] AC2 两个有效区域均可从配置指定的已导出 ZIP 道路包运行 Blender、Cesium、package 和 preview
构建期间没有 compiler 子进程。
- [ ] AC3 宿主在 Blender 前拒绝缺失包、错误 contract、错误 areaId 和无效 manifest错误说明
指向导入包和契约,而非 compiler 内部。
- [ ] AC4 Blender scene digest 与 P3 control 基线一致native semantic 层仍不生成几何。
- [ ] AC5 stage manifest / diagnose / check area 将导入道路包记录为输入并能识别缺失或变更。
- [ ] AC6 `osm2streets` legacy provider 行为未变。
- [ ] AC7 文档和配置将人工编辑传播路径表述为 road-compiler Web export -> host consume。
## Out of Scope
- road-compiler Web 的编辑器功能、认证、部署或 UI 改造。
- 修改道路几何、材质、Blender 建筑/植被/水体渲染。
- 新的 manifest 破坏性版本或 lane graph IR。
- 将道路包上传到远程制品库或实现同步服务。
## Key Decision
- Web 导出的是标准 ZIP宿主配置直接指向 ZIP 而不是目录。ZIP 是跨仓库的不可变交付物,
宿主只在私有 staging 目录中解压和消费,因而没有导出目录 ownership 或覆盖歧义。
## Subtask Map
| 子任务 | 仓库 | 交付物 | 依赖 |
|---|---|---|---|
| `rc-p5a-zip-export` | road-compiler | Web / CLI 可导出的 root-flat `native-road-package/v1.1` ZIP | 无 |
| `rc-p5b-zip-import` | osmWorkflow | 配置、受控解压、校验和纯消费管线 | p5a 的 ZIP fixture |
跨任务验收:同一个 ZIP fixture 必须被 p5b 的两个有效区域构建消费;宿主不得保留
compiler npm 或 CLI 耦合。

View File

@@ -0,0 +1,29 @@
{
"id": "rc-p5-artifact-consumption",
"name": "rc-p5-artifact-consumption",
"title": "道路包导入与纯消费管线",
"description": "让宿主通过配置指向 road-compiler Web 导出的 ZIP 道路包,只校验和消费该版本化文件契约,不再安装或调用编译器。",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "experiment/road-compiler-rethink",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"08-26-rc-p5b-zip-import",
"08-26-rc-p5a-zip-export"
],
"parent": "08-25-road-compiler-extraction",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

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

View File

@@ -0,0 +1,22 @@
# P5A ZIP Export Design
The compiler owns a pure `exportNativeRoadPackage(result, destination)` function. It writes a
temporary root-flat directory from the already compiled Web/CLI state, validates it with the same
manifest/source validator used by compiler output, then writes a deterministic ZIP. The Web download
handler and CLI are thin callers of this function.
ZIP layout:
```text
manifest.json
compiled.json
diagnostics.json
comparison.json
traffic-signal-assemblies.json
traffic-signals.json
layers/<12 declared sources>.geojson
```
`manifest.generator` is informational only. The host accepts solely the `contract`, `areaId`, and
declared files. ZIP creation normalizes entry order and timestamps so the export is hash-stable.
Failure never replaces a chosen output file.

View File

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

View File

@@ -0,0 +1,10 @@
# P5A Execution Plan
1. Inspect workbench state ownership and compiler output writer; keep export sourced from the current
compiled state, not host paths.
2. Add deterministic root-flat ZIP writer plus validation and CLI entry point.
3. Add Web download action using the same writer.
4. Add fixtures for root layout, content determinism, manifest/source parity and edited-state export.
5. Export both supported-area fixtures for P5B, run compiler tests, then publish a compiler release.
Rollback: remove the export entry point; no host behavior changes in this subtask.

View File

@@ -0,0 +1,34 @@
# Web 导出原生道路 ZIP 包
## Goal
让 road-compiler Web/CLI 以 root-flat native-road-package/v1.1 ZIP 导出人工编辑后的道路包。
## Requirements
- 从 Web 当前编辑状态导出完整道路包而不是源代码、npm 包或仅 overrides。
- ZIP 根直接包含 `manifest.json`,并包含全部 manifest 声明的 `layers/<source>.geojson`
`compiled.json``traffic-signals.json` 和 preview 所需的 signal / diagnostics 文档。
- `manifest.json``contract` 保持 `native-road-package/v1.1`;新增可选 `generator` 元数据
记录应用版本和 revision但不得让宿主依赖该版本。
- Web 提供用户可见的下载操作CLI 提供等价、可测试的 ZIP 导出入口。
- ZIP 条目顺序、JSON 序列化和压缩时间戳必须确定性,重复导出未编辑内容的文件 hash 一致。
## Acceptance Criteria
- [x] 从 Web 编辑后的区域可下载一个 root-flat ZIP且无需宿主或 npm 发布。
- [x] CLI fixture 导出 ZIP 后,解压内容通过 compiler 的 `validatePublishedLayers()`
- [x] ZIP 具有恰好一份 manifest 声明的 layer source无额外 compiler source / overrides 文件。
- [ ] 两个有效区域导出的 ZIP 可被 p5b 导入并完成 Blender/Cesium/preview 构建。
- [x] 不修改 `compiled.json` 与 v1.1 manifest 的既有渲染语义。
## Out of Scope
- 远程存储、上传、项目历史或多用户协作。
- 修改道路编辑器交互、几何算法或 manifest 主版本。
## Key Decisions
- ZIP 是单个标准交付物,根目录平铺 package 文件,不包裹 area-id 顶层目录。
- ZIP 面向宿主消费,只携带已编译渲染/预览数据编辑源、OSM 与 overrides 留在 road-compiler
项目中,不泄露为宿主依赖。

View File

@@ -0,0 +1,26 @@
{
"id": "rc-p5a-zip-export",
"name": "rc-p5a-zip-export",
"title": "Web 导出原生道路 ZIP 包",
"description": "让 road-compiler Web/CLI 以 root-flat native-road-package/v1.1 ZIP 导出人工编辑后的道路包。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "experiment/road-compiler-rethink",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-rc-p5-artifact-consumption",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

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

View File

@@ -0,0 +1,19 @@
# P5B ZIP Import Design
`scripts/lib/native-road-package.js` becomes the single host adapter. It resolves the config-relative
ZIP path, hashes it, validates central-directory entry names and metadata, and extracts only accepted
regular files to `_pipeline/native-road-import/<sha256>/`. It validates the unpacked manifest and
returns an immutable package descriptor:
```text
{ zipFile, zipRecord, rootDir, manifest, records }
```
The descriptor replaces `area.outputs.nativeRoadDir` as native input for Blender, native preview traffic,
diagnostics and stage manifests. The configured ZIP and unpacked cache are inputs, never published area
outputs. Cache invalidation is by ZIP SHA-256; cleanup only deletes importer-owned hash directories.
The importer rejects any non-root-flat layout, duplicate path, symlink, unsupported compression/error,
path traversal, invalid manifest, area mismatch or declared/published mismatch before Blender starts.
The native compiler CLI dependency and host-owned `nativeRoad` compilation options are removed. Legacy
`osm2streets` remains an explicit separate provider.

View File

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

View File

@@ -0,0 +1,12 @@
# P5B Execution Plan
1. Add config normalization for `nativeRoadPackage`, migrate two supported configs and the template.
2. Add the tested ZIP importer with bounded extraction, root-flat/path/symlink validation, SHA cache and
manifest/area validation.
3. Route Blender, preview traffic, stage manifests and diagnostics through the imported descriptor.
4. Delete compiler CLI/compile production paths and package dependency; replace their tests with ZIP fixture
and negative importer tests.
5. Update docs/specs, run native/legacy unit suites, build two exported packages, then run Blender parity.
Rollback: restore the old compiler dependency and native compile path in one revert; ZIP exports remain
non-destructive standalone files.

View File

@@ -0,0 +1,40 @@
# ZIP 道路包安全导入与纯消费
## Goal
让 osmWorkflow 配置、校验、解压并消费 road-compiler Web 导出的 ZIP 道路包,移除 compiler runtime 耦合。
## Requirements
- 区域配置以 `nativeRoadPackage` 指向 ZIP路径相对于该区域配置文件native provider 不再
接受 compiler options、overrides 或输出目录作为输入。
- 宿主在任何 Blender / Cesium 副作用前,验证 ZIP 文件、条目数量/大小、root-flat 文件路径、
无重复路径、无符号链接、无绝对路径与 `..` 路径。
- 仅将已验证的文件解压至 `<area>/_pipeline/native-road-import/<zip-sha256>/`;解压目录是宿主
私有缓存,绝不写回 ZIP 或 road-compiler 导出目录。
- 共享 importer 为 Blender、preview traffic、diagnose/check 和 stage manifest 提供同一包路径与
文件记录;不能让下游自行按字符串拼接另一份 native-road 目录。
- 移除 compiler package、CLI wrapper、compile script、编译器输入配置、以及只覆盖旧调用链的测试。
## Acceptance Criteria
- [ ] 两个有效区域均从 ZIP 完成 `blender,cesium,package,preview`,构建日志没有 compiler CLI。
- [ ] 缺 ZIP、错误 ZIP、nested root、path traversal、symbolic link、错误 contract、错误 areaId 和
manifest/source 不匹配均在启动 Blender 前失败。
- [ ] stage manifest、`diagnose:area``check:area` 记录 ZIP hash 和已验证的导入目录ZIP
修改后会被判 stale。
- [ ] `package.json`、lockfile、生产脚本与宿主测试不再引用 `@osm-asset/road-compiler`
- [ ] Blender digest 对 P3 control 基线一致legacy `osm2streets` 路径的单元测试不变。
## Out of Scope
- p5a 的 ZIP 生成与 Web 下载实现。
- ZIP 远程下载、解密、签名验证或制品库管理。
- 改道路内容、材质或 legacy QGIS 行为。
## Key Decisions
- ZIP 是只读输入;宿主缓存的生命周期和目录 ownership 完全属于 `_pipeline/`
- ZIP 解码由明确声明的 Node ZIP 库完成,而不是依赖系统 `unzip`;这使路径/符号链接校验可控并
可在单元测试中覆盖。
- 只有 `native-road-package/v1.1` 可被导入;契约升级必须另开兼容任务。

View File

@@ -0,0 +1,26 @@
{
"id": "rc-p5b-zip-import",
"name": "rc-p5b-zip-import",
"title": "ZIP 道路包安全导入与纯消费",
"description": "让 osmWorkflow 配置、校验、解压并消费 road-compiler Web 导出的 ZIP 道路包,移除 compiler runtime 耦合。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "experiment/road-compiler-rethink",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-26-rc-p5-artifact-consumption",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -3,7 +3,7 @@
"name": "rc-p3-render-separation",
"title": "Phase 3道路与建筑渲染分离",
"description": "编译器输出 layer manifest 自声明图层Blender 改为读 manifest道路渲染从 generate_scene.py 抽离,消除跨仓库重复图层表",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-25",
"completedAt": null,
"completedAt": "2026-08-26",
"branch": null,
"base_branch": "main",
"worktree_path": null,

View File

@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 44
- **Total Sessions**: 45
- **Last Active**: 2026-08-26
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~955 | Active |
| `journal-1.md` | ~976 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 45 | 2026-08-26 | Complete manifest-driven native road rendering | `6201270` | `experiment/road-compiler-rethink` |
| 44 | 2026-08-26 | Complete external road compiler split | `5cde080`, `7d4b3b5` | `experiment/road-compiler-rethink` |
| 43 | 2026-08-25 | Extract road compiler package boundary | `73707da`, `d5c58b5`, `f542a64`, `284d3db`, `2469cfd` | `experiment/road-compiler-rethink` |
| 42 | 2026-08-25 | Freeze native road parity baseline | `e7bc7f8` | `experiment/road-compiler-rethink` |

View File

@@ -953,3 +953,24 @@ Split road compiler into private road-compiler repository with history, pushed v
### Status
[OK] **Completed**
## Session 45: Complete manifest-driven native road rendering
**Date**: 2026-08-26
**Task**: Complete manifest-driven native road rendering
**Branch**: `experiment/road-compiler-rethink`
### Summary
Published compiler v0.2.2, switched Blender to validated native-road manifests, proved debug-probe extensibility, and verified Blender parity for fengshu-er-road.
### Git Commits
| Hash | Message |
|------|---------|
| `6201270` | (see git log) |
### Status
[OK] **Completed**

View File

@@ -1,6 +1,7 @@
{
"id": "fengshu-er-road",
"input": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/枫树二路.osm",
"nativeRoadPackage": "../../inputs/native-road-packages/fengshu-er-road.zip",
"outputRoot": "/Users/que01/osm2streets-qgis-workflow/outputs",
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
@@ -24,31 +25,6 @@
"turnLaneArrows": {
"enabled": false
},
"nativeRoad": {
"edgeLines": false,
"junctionTemplates": {
"enabled": true,
"references": [],
"clusters": [
{
"id": "zhushanhu-fengshu-complex",
"template": "complex-junction-v1",
"referenceFile": "../../inputs/osm/珠山湖大道(枫树二路)口.geojson",
"approachWidthMultiplier": 1.45,
"approachLengthMeters": 32,
"coreRadiusMeters": 28,
"cornerRadiusMeters": 25,
"outerRadiusExtraMeters": 24,
"nodeIds": [
"8005332807",
"8024512135",
"8024512145",
"8024512147"
]
}
]
}
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -1,6 +1,7 @@
{
"id": "nantaizi-lake-innovation-valley",
"input": "/Users/que01/Desktop/南台子湖创新谷OSM.osm",
"nativeRoadPackage": "../../inputs/native-road-packages/nantaizi-lake-innovation-valley.zip",
"outputRoot": "/Users/que01/osm2streets-qgis-workflow/outputs",
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
@@ -24,9 +25,6 @@
"turnLaneArrows": {
"enabled": true
},
"nativeRoad": {
"edgeLines": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -1,6 +1,7 @@
{
"id": "my-area",
"input": "/absolute/path/to/input.osm",
"nativeRoadPackage": "/absolute/path/to/exported-native-road-package.zip",
"outputRoot": "/absolute/path/to/outputs",
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
@@ -24,14 +25,6 @@
"turnLaneArrows": {
"enabled": false
},
"nativeRoad": {
"edgeLines": false,
"junctionTemplates": {
"enabled": false,
"references": [],
"clusters": []
}
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

Binary file not shown.

1
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.2.2",
"fflate": "0.8.3",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}

View File

@@ -11,14 +11,10 @@
"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",
"reference:junction": "node scripts/inspect-junction-reference.js",
"road:check": "node scripts/check-native-roads.js",
"road:workbench": "node scripts/road-workbench.js",
"serve:v2x-preview": "node scripts/v2x-preview-server.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:road-compiler-cli": "node scripts/test-road-compiler-cli.js",
"test:native-road-package": "node scripts/test-native-road-package.js",
"test:road-parity": "node scripts/test-road-parity.js",
"test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js",
"test:preflight": "node scripts/test-area-preflight.js",
@@ -38,6 +34,7 @@
"dependencies": {
"@inquirer/prompts": "^8.5.2",
"@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.2.2",
"fflate": "0.8.3",
"ol": "^10.10.0",
"osm2streets-js-node": "0.1.4"
}

View File

@@ -4,7 +4,7 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileNativeRoads } = require("./lib/road-compiler-cli");
const { importNativeRoadPackage } = require("./lib/native-road-package");
const { resolveStages } = require("./lib/build-stages");
const { validateManifest, addIntegrity } = require("./lib/package-contract");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
@@ -225,10 +225,7 @@ function buildBlenderScene(area, roadProvider) {
}
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
if (roadProvider === "native") {
compileNativeRoads(area);
ensureNativeRoadLayers(area);
}
const nativePackage = roadProvider === "native" ? importNativeRoadPackage(area) : null;
const blenderArgs = [
"--background",
@@ -246,8 +243,8 @@ function buildBlenderScene(area, roadProvider) {
area.blender.treeStyle,
];
if (roadProvider === "native") {
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json"));
blenderArgs.push("--native-road", nativePackage.rootDir);
blenderArgs.push("--traffic-signals", path.join(nativePackage.rootDir, "traffic-signals.json"));
} else {
blenderArgs.push("--geojson", area.outputs.geojsonDir);
}
@@ -270,7 +267,7 @@ function buildBlenderScene(area, roadProvider) {
inputs: {
config: fileRecord(configPath),
osm: fileRecord(area.input),
...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)),
...(roadProvider === "native" ? nativeRoadRecords(nativePackage) : sceneGeojsonRecords(area)),
...(roadProvider === "osm2streets" ? {
geojsonDir: fileRecord(area.outputs.geojsonDir),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
@@ -282,7 +279,7 @@ function buildBlenderScene(area, roadProvider) {
render: fileRecord(area.outputs.render),
},
summary: {
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }),
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(nativePackage) } : { geojson: geojsonFeatureCounts(area) }),
roadProvider,
blendBytes: fileRecord(area.outputs.blend).bytes,
renderBytes: fileRecord(area.outputs.render).bytes,
@@ -291,17 +288,13 @@ function buildBlenderScene(area, roadProvider) {
});
}
function ensureNativeRoadLayers(area) {
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
for (const file of ["road_surface.geojson", "edge_lines.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "center_lines.geojson", "direction_arrows.geojson", "turn_arrows.geojson", "crosswalks.geojson", "vehicle_stop_lines.geojson"]) {
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
}
}
function nativeRoadRecords(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
function nativeRoadRecords(nativePackage) {
const root = path.join(nativePackage.rootDir, "layers");
return {
nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")),
nativeRoadZip: nativePackage.zipRecord,
nativeRoadImport: fileRecord(nativePackage.rootDir),
nativeRoadCompiled: fileRecord(path.join(nativePackage.rootDir, "compiled.json")),
nativeRoadSignals: fileRecord(path.join(nativePackage.rootDir, "traffic-signals.json")),
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
nativeEdgeLines: fileRecord(path.join(root, "edge_lines.geojson")),
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
@@ -315,8 +308,8 @@ function nativeRoadRecords(area) {
};
}
function nativeRoadFeatureCounts(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
function nativeRoadFeatureCounts(nativePackage) {
const root = path.join(nativePackage.rootDir, "layers");
return {
roadSurface: featureCount(path.join(root, "road_surface.geojson")),
edgeLines: featureCount(path.join(root, "edge_lines.geojson")),
@@ -487,8 +480,9 @@ function compressCesiumGlb(area) {
function publishPackage(area, roadProvider) {
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
const trafficSignalsSource = roadProvider === "native"
? path.join(area.outputs.nativeRoadDir, "traffic-signals.json")
const nativePackage = roadProvider === "native" ? importNativeRoadPackage(area) : null;
const trafficSignalsSource = nativePackage
? path.join(nativePackage.rootDir, "traffic-signals.json")
: area.outputs.trafficSignals;
ensureFile(trafficSignalsSource, "Traffic signal anchors");
const started = Date.now();
@@ -574,7 +568,7 @@ function writeCesiumPreview(area, roadProvider) {
intersectionSurface: fileRecord(intersectionSurface),
});
} else {
Object.assign(previewInputs, nativeRoadRecords(area));
Object.assign(previewInputs, nativeRoadRecords(importNativeRoadPackage(area)));
// This operational preview intentionally has no synthetic vehicle source.
// A stale descriptor from an older build must not resurrect simulated routes.
fs.rmSync(area.outputs.trafficSimulation, { force: true });

View File

@@ -25,6 +25,10 @@ function normalizeAreaConfig(raw, options = {}) {
const fileStem = outputOverrides.fileStem || id;
const compress = normalizeCompressConfig(raw.compress);
const budget = normalizeBudgetConfig(raw.budget);
const roadProvider = roadProviderOption(raw.blender?.roadProvider ?? "native");
if (roadProvider === "native" && raw.nativeRoadPackage === undefined) {
throw new Error("Missing config key: nativeRoadPackage");
}
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"));
@@ -32,13 +36,9 @@ function normalizeAreaConfig(raw, options = {}) {
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")),
nativeTrafficSignals: path.resolve(outputOverrides.nativeTrafficSignals || path.join(areaDir, "native-traffic-signals.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`)),
@@ -81,6 +81,9 @@ function normalizeAreaConfig(raw, options = {}) {
return {
id,
nativeRoadPackage: raw.nativeRoadPackage === undefined
? null
: path.resolve(options.configDir || repoRoot, requireText(raw.nativeRoadPackage, "nativeRoadPackage")),
input,
outputRoot,
qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"),
@@ -111,10 +114,6 @@ function normalizeAreaConfig(raw, options = {}) {
turnLaneArrows: {
enabled: booleanOption(raw.turnLaneArrows?.enabled, false, "turnLaneArrows.enabled"),
},
nativeRoad: {
edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"),
junctionTemplates: normalizeJunctionTemplates(raw.nativeRoad?.junctionTemplates, repoRoot, options.configDir || repoRoot),
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,
@@ -125,7 +124,7 @@ function normalizeAreaConfig(raw, options = {}) {
blender: {
treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"),
roadProvider,
},
v2xPreview: normalizeV2xPreviewConfig(raw.v2xPreview),
compress,
@@ -272,24 +271,8 @@ function booleanOption(value, fallback, label) {
throw new Error(`${label} must be boolean`);
}
function toRoadCompilerInput(area) {
return {
areaId: area.id,
osmFile: area.input,
outDir: area.outputs.nativeRoadDir,
stagingDir: area.outputs.pipelineDir,
overridesFile: area.outputs.nativeRoadOverrides,
trafficSignalsFile: area.outputs.nativeTrafficSignals,
comparisonDir: area.outputs.geojsonDir,
options: {
edgeLines: area.nativeRoad.edgeLines,
junctionTemplates: area.nativeRoad.junctionTemplates,
},
};
}
module.exports = {
normalizeAreaConfig,
readAreaConfig,
toRoadCompilerInput,
};

View File

@@ -3,6 +3,7 @@
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./area-config");
const { importNativeRoadPackage } = require("./native-road-package");
const {
SCENE_LAYERS,
SCENE_FILE,
@@ -324,6 +325,11 @@ function addEndpoint(map, ref) {
function artifactStatus(area) {
const native = area.blender.roadProvider === "native";
let nativePackage = null;
let nativePackageError = null;
if (native) {
try { nativePackage = importNativeRoadPackage(area); } catch (error) { nativePackageError = error.message; }
}
const entries = [
...(native ? [] : [
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
@@ -332,15 +338,16 @@ function artifactStatus(area) {
["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"],
["Native road ZIP", area.nativeRoadPackage, native, "file"],
["Native road import", nativePackage?.rootDir || path.join(area.outputs.pipelineDir, "native-road-import"), native, "dir"],
["Traffic signal runtime", native ? path.join(nativePackage?.rootDir || "", "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"],
["Package primary GLB", area.outputs.packagePrimaryGlb, true, "file"],
["Cesium preview", area.outputs.cesiumPreview, true, "file"],
];
return entries.map(([label, file, expected, type]) => {
const result = entries.map(([label, file, expected, type]) => {
const exists = fs.existsSync(file);
const stat = exists ? fs.statSync(file) : null;
const validType = !exists || (type === "dir" ? stat.isDirectory() : stat.isFile());
@@ -358,6 +365,8 @@ function artifactStatus(area) {
geojsonFiles,
};
});
if (nativePackageError) result.push({ label: "Native road ZIP validation", path: area.nativeRoadPackage, expected: true, type: "file", exists: false, validType: false, error: nativePackageError });
return result;
}
function metadataSummary(file, warnings) {
@@ -378,6 +387,10 @@ function metadataSummary(file, warnings) {
function stageManifestStatus(area, configPath = null) {
const native = area.blender.roadProvider === "native";
let nativePackage = null;
if (native) {
try { nativePackage = importNativeRoadPackage(area); } catch (_) { /* artifact status reports the validation failure */ }
}
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
const reimportManifest = stageManifestPath(area, "reimport");
const hasReimportManifest = fs.existsSync(reimportManifest);
@@ -435,7 +448,7 @@ function stageManifestStatus(area, configPath = null) {
inputs: {
...(configPath ? { config: configPath } : {}),
osm: area.input,
...(native ? nativeRoadRecords(area) : {
...(native && nativePackage ? nativeRoadRecords(nativePackage) : {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
@@ -468,18 +481,16 @@ function stageManifestStatus(area, configPath = null) {
glb: area.outputs.glb,
metadata: area.outputs.metadata,
}),
...(native ? nativeRoadRecords(area) : {
...(native && nativePackage ? nativeRoadRecords(nativePackage) : {
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 ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
},
outputs: compressionComplete ? {
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
vehicleModel: area.outputs.vehicleModel,
} : {
cesiumPreview: area.outputs.cesiumPreview,
@@ -609,11 +620,13 @@ function sceneGeojsonFiles(area) {
return files;
}
function nativeRoadRecords(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
function nativeRoadRecords(nativePackage) {
const root = path.join(nativePackage.rootDir, "layers");
return {
nativeRoadCompiled: path.join(area.outputs.nativeRoadDir, "compiled.json"),
nativeRoadSignals: path.join(area.outputs.nativeRoadDir, "traffic-signals.json"),
nativeRoadZip: nativePackage.zipFile,
nativeRoadImport: nativePackage.rootDir,
nativeRoadCompiled: path.join(nativePackage.rootDir, "compiled.json"),
nativeRoadSignals: path.join(nativePackage.rootDir, "traffic-signals.json"),
nativeRoadSurface: path.join(root, "road_surface.geojson"),
nativeEdgeLines: path.join(root, "edge_lines.geojson"),
nativeIntersectionSurface: path.join(root, "intersection_surface.geojson"),

View File

@@ -19,7 +19,8 @@ const DEFAULT_SETTINGS = {
};
function buildNativeTrafficSimulation(area, options = {}) {
const root = area.outputs.nativeRoadDir;
const root = options.nativePackage?.rootDir;
if (!root) throw new Error("Native traffic simulation requires an imported native road package");
const compiledPath = path.join(root, "compiled.json");
const signalPath = path.join(root, "traffic-signals.json");
const connectorPath = path.join(root, "layers", "connectors.geojson");

View File

@@ -0,0 +1,87 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { unzipSync } = require("fflate");
const CONTRACT = "native-road-package/v1.1";
const ROOT_FILES = ["manifest.json", "compiled.json", "diagnostics.json", "comparison.json", "traffic-signal-assemblies.json", "traffic-signals.json"];
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
function readCentralEntries(bytes) {
const entries = [];
for (let i = 0; i + 46 <= bytes.length; i += 1) {
if (bytes.readUInt32LE(i) !== 0x02014b50) continue;
const nameLength = bytes.readUInt16LE(i + 28);
const extraLength = bytes.readUInt16LE(i + 30);
const commentLength = bytes.readUInt16LE(i + 32);
const name = bytes.subarray(i + 46, i + 46 + nameLength).toString("utf8");
const externalAttrs = bytes.readUInt32LE(i + 38);
entries.push({ name, externalAttrs, directory: name.endsWith("/") });
i += 45 + nameLength + extraLength + commentLength;
}
if (!entries.length) throw new Error("ZIP central directory is missing");
return entries;
}
function validateName(name) {
if (!name || path.posix.isAbsolute(name) || name.split("/").includes("..") || name.includes("\\")) throw new Error(`Unsafe ZIP entry path: ${name}`);
if (name.startsWith("./") || (name.includes("/") && !name.startsWith("layers/")) || (name.startsWith("layers/") && name.split("/").length !== 2)) throw new Error(`Native road ZIP must be root-flat: ${name}`);
}
function validateManifest(rootDir, areaId) {
let manifest;
try { manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "manifest.json"), "utf8")); } catch (error) { throw new Error(`Invalid native road manifest: ${error.message}`); }
if (!manifest || manifest.contract !== CONTRACT) throw new Error(`Unsupported native road contract: ${manifest?.contract || "missing"}`);
if (manifest.areaId !== areaId) throw new Error(`Native road areaId mismatch: expected ${areaId}, got ${manifest.areaId}`);
if (!Array.isArray(manifest.layers) || !manifest.layers.length) throw new Error("Native road manifest layers are missing");
const declared = new Set();
for (const layer of manifest.layers) {
if (!layer || typeof layer.source !== "string" || layer.source !== path.posix.basename(layer.source) || declared.has(layer.source)) throw new Error("Invalid native road layer declaration");
if (!["surface", "marking", "semantic"].includes(layer.role)) throw new Error(`Invalid native road layer role: ${layer.role}`);
if (layer.role !== "semantic" && typeof layer.materialLayer !== "string") throw new Error(`Renderable layer ${layer.source} has no materialLayer`);
declared.add(layer.source);
}
const published = new Set(fs.readdirSync(path.join(rootDir, "layers")).filter((name) => name.endsWith(".geojson")).map((name) => name.slice(0, -8)));
if (declared.size !== published.size || [...declared].some((name) => !published.has(name))) throw new Error("Native road manifest/source mismatch");
return manifest;
}
function importNativeRoadPackage(area) {
const zipFile = area.nativeRoadPackage;
if (!zipFile || !fs.existsSync(zipFile) || !fs.statSync(zipFile).isFile()) throw new Error(`Native road ZIP not found: ${zipFile || "missing nativeRoadPackage"}`);
const bytes = fs.readFileSync(zipFile);
const hash = crypto.createHash("sha256").update(bytes).digest("hex");
const entries = readCentralEntries(bytes);
const names = new Set();
for (const entry of entries) {
validateName(entry.name);
if (names.has(entry.name)) throw new Error(`Duplicate ZIP entry: ${entry.name}`);
names.add(entry.name);
const unixMode = entry.externalAttrs >>> 16;
if ((unixMode & 0xf000) === 0xa000) throw new Error(`Symbolic links are not allowed: ${entry.name}`);
}
let files;
try { files = unzipSync(bytes); } catch (error) { throw new Error(`Invalid ZIP archive: ${error.message}`); }
const expected = new Set([...ROOT_FILES, ...[...names].filter((name) => name.startsWith("layers/") && name.endsWith(".geojson"))]);
for (const name of ROOT_FILES) if (!names.has(name)) throw new Error(`Missing native road ZIP entry: ${name}`);
for (const name of names) if (!expected.has(name) || (!name.startsWith("layers/") && !ROOT_FILES.includes(name))) throw new Error(`Unexpected native road ZIP entry: ${name}`);
const rootDir = path.join(area.outputs.pipelineDir, "native-road-import", hash);
if (!fs.existsSync(rootDir)) {
fs.mkdirSync(path.join(rootDir, "layers"), { recursive: true });
for (const [name, data] of Object.entries(files)) {
if (!names.has(name) || name.endsWith("/")) continue;
const destination = path.join(rootDir, name);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, data);
}
}
const manifest = validateManifest(rootDir, area.id);
const records = {};
for (const name of names) { const file = path.join(rootDir, name); if (fs.existsSync(file) && fs.statSync(file).isFile()) records[name] = { path: file, bytes: fs.statSync(file).size, sha256: sha256(file) }; }
return Object.freeze({ zipFile, zipRecord: { path: zipFile, bytes: bytes.length, sha256: hash }, rootDir, manifest, records });
}
module.exports = { CONTRACT, importNativeRoadPackage };

View File

@@ -5,6 +5,7 @@ const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { importNativeRoadPackage } = require("./lib/native-road-package");
const repoRoot = path.resolve(__dirname, "..");
@@ -77,10 +78,11 @@ function filesBelow(directory) {
function snapshot(configPath) {
const area = readAreaConfig(configPath, { repoRoot });
if (!fs.existsSync(area.outputs.nativeRoadDir)) throw new Error(`Native road output not found: ${area.outputs.nativeRoadDir}`);
const nativePackage = importNativeRoadPackage(area);
const nativeRoadDir = nativePackage.rootDir;
const files = {};
for (const file of filesBelow(area.outputs.nativeRoadDir).sort()) {
const relative = path.relative(area.outputs.nativeRoadDir, file).split(path.sep).join("/");
for (const file of filesBelow(nativeRoadDir).sort()) {
const relative = path.relative(nativeRoadDir, file).split(path.sep).join("/");
const text = fs.readFileSync(file, "utf8");
files[relative] = jsonRecord(text, Buffer.byteLength(text));
}

View File

@@ -19,7 +19,7 @@ assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
assert.match(areaBuildSource, /"--dynamic-glb", area\.outputs\.trafficSignalsDynamicGlb/);
assert.match(areaBuildSource, /trafficSignalsCountdown0Glb: fileRecord/);
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{[\s\S]*?buildPreviewVehicleRoute/);
assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(area\)\)/);
assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(/);
assert.match(areaBuildSource, /vehicleRoute: optionalFileRecord\(area\.outputs\.vehicleRoute\)/);
const gltf = {
@@ -48,7 +48,7 @@ assert.equal(evaluateGlbBudget(digest, budget).violations[0].key, "glbBytes");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-budget-"));
const input = path.join(tempDir, "input.osm");
fs.writeFileSync(input, "<osm/>");
const base = { id: "test-area", input, outputRoot: tempDir };
const base = { id: "test-area", input, outputRoot: tempDir, blender: { roadProvider: "osm2streets" } };
assert.equal(
normalizeAreaConfig(base).outputs.trafficSignals,
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signals.json"),
@@ -63,7 +63,7 @@ assert.equal(
);
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
assert.equal(normalizeAreaConfig(base).stages.intermediates, false);
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native");
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets");
assert.deepEqual(normalizeAreaConfig(base).v2xPreview, {
enabled: false,
apiBaseUrl: "/api",
@@ -80,7 +80,7 @@ assert.throws(
() => normalizeAreaConfig({ ...base, v2xPreview: "enabled" }),
/v2xPreview must be an object/,
);
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
assert.equal(normalizeAreaConfig({ ...base, nativeRoadPackage: path.join(tempDir, "roads.zip"), blender: { roadProvider: "native" } }).blender.roadProvider, "native");
assert.throws(
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
/blender\.roadProvider/,

View File

@@ -25,7 +25,7 @@ fs.writeFileSync(path.join(layersDir, "connectors.geojson"), JSON.stringify({ ty
fs.writeFileSync(path.join(layersDir, "vehicle_stop_lines.geojson"), JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { road_id: "road:a" }, geometry: { type: "Polygon", coordinates: [] } }] }));
fs.writeFileSync(path.join(layersDir, "lane_centerlines.geojson"), JSON.stringify({ type: "FeatureCollection", features: roads.map((road) => ({ type: "Feature", properties: { road_id: road.id, lane_index: 1 }, geometry: { type: "LineString", coordinates: road.centerline } })) }));
const descriptor = buildNativeTrafficSimulation({ id: "fixture", outputs: { areaDir, nativeRoadDir } });
const descriptor = buildNativeTrafficSimulation({ id: "fixture", outputs: { areaDir } }, { nativePackage: { rootDir: nativeRoadDir } });
assert.equal(descriptor.schema, SCHEMA);
assert.equal(descriptor.coordinateSystem.route, "WGS84");
assert.ok(descriptor.routes.length >= 1);

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { zipSync } = require("fflate");
const { importNativeRoadPackage } = require("./lib/native-road-package");
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-package-"));
const area = { id: "fixture", nativeRoadPackage: "", outputs: { pipelineDir: path.join(temp, "pipeline") } };
const layers = ["road_surface", "connectors"];
const manifest = { contract: "native-road-package/v1.1", areaId: "fixture", layers: [
{ source: "road_surface", role: "surface", materialLayer: "road_surface" },
{ source: "connectors", role: "semantic" },
] };
function validEntries() {
return {
"manifest.json": Buffer.from(JSON.stringify(manifest)),
"compiled.json": Buffer.from("{}"), "diagnostics.json": Buffer.from("{}"), "comparison.json": Buffer.from("{}"),
"traffic-signal-assemblies.json": Buffer.from("{}"), "traffic-signals.json": Buffer.from("{}"),
...Object.fromEntries(layers.map((layer) => [`layers/${layer}.geojson`, Buffer.from('{"type":"FeatureCollection","features":[]}')])),
};
}
function write(name, entries = validEntries()) { const file = path.join(temp, name); fs.writeFileSync(file, zipSync(entries)); area.nativeRoadPackage = file; return file; }
write("valid.zip");
const first = importNativeRoadPackage(area);
assert.equal(first.manifest.areaId, "fixture");
assert.equal(Object.keys(first.records).length, 8);
assert.ok(first.rootDir.includes(first.zipRecord.sha256));
assert.equal(importNativeRoadPackage(area).rootDir, first.rootDir);
assert.throws(() => { area.nativeRoadPackage = path.join(temp, "missing.zip"); importNativeRoadPackage(area); }, /ZIP not found/);
write("corrupt.zip", { "manifest.json": Buffer.from("{") });
assert.throws(() => importNativeRoadPackage(area), /ZIP central directory|Invalid ZIP|Invalid native road manifest|Missing native road ZIP entry/);
write("nested.zip", { "nested/manifest.json": Buffer.from("{}") });
assert.throws(() => importNativeRoadPackage(area), /root-flat/);
write("traversal.zip", { "../manifest.json": Buffer.from("{}") });
assert.throws(() => importNativeRoadPackage(area), /Unsafe ZIP entry/);
const wrongContract = validEntries(); wrongContract["manifest.json"] = Buffer.from(JSON.stringify({ ...manifest, contract: "v1" })); write("contract.zip", wrongContract);
assert.throws(() => importNativeRoadPackage(area), /Unsupported native road contract/);
const wrongArea = validEntries(); wrongArea["manifest.json"] = Buffer.from(JSON.stringify({ ...manifest, areaId: "other" })); write("area.zip", wrongArea);
assert.throws(() => importNativeRoadPackage(area), /areaId mismatch/);
const mismatch = validEntries(); delete mismatch["layers/connectors.geojson"]; write("mismatch.zip", mismatch);
assert.throws(() => importNativeRoadPackage(area), /manifest\/source mismatch/);
const symlink = write("symlink.zip");
const data = fs.readFileSync(symlink); const index = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); data.writeUInt32LE((0xa000 << 16) >>> 0, index + 38); fs.writeFileSync(symlink, data);
assert.throws(() => importNativeRoadPackage(area), /Symbolic links/);
fs.rmSync(temp, { recursive: true, force: true });
console.log("Native road ZIP package tests passed.");