From 620127031ec79f2b83136e680cbda681e20d3f3f Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 26 Aug 2026 10:55:47 +0800 Subject: [PATCH] feat: consume manifest-driven native roads --- .trellis/spec/pipeline/native-road-package.md | 2 +- .../08-25-rc-p3-render-separation/check.jsonl | 6 +- .../08-25-rc-p3-render-separation/design.md | 61 ++++++++++++ .../implement.jsonl | 6 +- .../implement.md | 46 ++++----- .../08-25-rc-p3-render-separation/prd.md | 14 +-- .../research/debug-probe-evidence.md | 19 ++++ .../08-25-rc-p3-render-separation/task.json | 2 +- blender/generate_scene.py | 28 +----- blender/osmassets/catalog.py | 14 --- blender/osmassets/native_road_manifest.py | 95 +++++++++++++++++++ blender/osmassets/native_roads.py | 51 ++++++++++ blender/tests/test_pure.py | 82 ++++++++++++---- docs/native-road-package-v1.md | 12 ++- package-lock.json | 6 +- package.json | 2 +- 16 files changed, 349 insertions(+), 97 deletions(-) create mode 100644 .trellis/tasks/08-25-rc-p3-render-separation/design.md create mode 100644 .trellis/tasks/08-25-rc-p3-render-separation/research/debug-probe-evidence.md create mode 100644 blender/osmassets/native_road_manifest.py create mode 100644 blender/osmassets/native_roads.py diff --git a/.trellis/spec/pipeline/native-road-package.md b/.trellis/spec/pipeline/native-road-package.md index 5f5b6f0..c941d56 100644 --- a/.trellis/spec/pipeline/native-road-package.md +++ b/.trellis/spec/pipeline/native-road-package.md @@ -22,7 +22,7 @@ road-compiler --input ## Contracts -The compiler writes `compiled.json`, `diagnostics.json`, `comparison.json`, signal runtime files, and GeoJSON layers under `outDir`. On success stdout contains exactly one marker whose JSON includes `areaId`, `roads`, `endpoints`, `diagnostics`, and `output`. +The compiler writes `compiled.json`, `diagnostics.json`, `comparison.json`, signal runtime files, `manifest.json`, and GeoJSON layers under `outDir`. Since `native-road-package/v1.1`, `manifest.json` is required: it declares every GeoJSON source, whether its role is renderable (`surface` / `marking`) or semantic, and the host material slot for renderable layers. The Blender adapter rejects a missing or invalid manifest, a declared/published GeoJSON mismatch, and an unknown material slot. On success stdout contains exactly one marker whose JSON includes `areaId`, `roads`, `endpoints`, `diagnostics`, and `output`. ## Validation & Error Matrix diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/check.jsonl b/.trellis/tasks/08-25-rc-p3-render-separation/check.jsonl index 9dd3234..c4dcfe3 100644 --- a/.trellis/tasks/08-25-rc-p3-render-separation/check.jsonl +++ b/.trellis/tasks/08-25-rc-p3-render-separation/check.jsonl @@ -1 +1,5 @@ -{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} +{"file":".trellis/spec/blender/module-structure.md","reason":"检查道路 adapter 与建筑/植被/水体模块的边界没有漂移。"} +{"file":".trellis/spec/blender/testing.md","reason":"检查 Blender 结构摘要、纯 Python schema 测试和提权运行要求。"} +{"file":".trellis/spec/pipeline/index.md","reason":"检查 compiler 输出与 Blender 消费的 manifest 数据流。"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查三层契约、材质槽和 source 文件名一致性。"} +{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"检查两个区域和 throwaway 图层验证后的产物差异。"} diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/design.md b/.trellis/tasks/08-25-rc-p3-render-separation/design.md new file mode 100644 index 0000000..3688301 --- /dev/null +++ b/.trellis/tasks/08-25-rc-p3-render-separation/design.md @@ -0,0 +1,61 @@ +# Phase 3:Manifest 驱动的道路渲染设计 + +## Boundary + +道路编译器是图层事实源,输出 `/manifest.json`;Blender 是渲染适配器,读取 manifest 并用宿主 `catalog.MATERIALS` / `ROAD_LAYERS` 完成材质与高度映射。建筑、植被、水体、OSM legacy 图层不改变。 + +``` +compiler CLI + -> native-road/manifest.json + 12 declared GeoJSON sources + -> generate_scene.py + -> osmassets/native_roads.py + -> catalog material/z lookup + -> roads.assemble_geojson_layer +``` + +## Manifest v1.1 + +`manifest.json` 顶层包含 `contract: "native-road-package/v1.1"`、`areaId` 和 `layers`。`layers` 必须恰好声明编译器输出的 12 个 source: + +- `road_surface`, `intersection_surface`, `sidewalk_surface`, `edge_lines`, `lane_separators`, `center_lines`, `crosswalks`, `vehicle_stop_lines`, `direction_arrows`, `turn_arrows` +- `lane_centerlines`, `connectors` + +每项字段: + +```json +{ + "source": "center_lines", + "role": "marking", + "materialLayer": "center_lines", + "splitBy": { + "prop": "color", + "cases": [ + {"match": "white", "material": "native_center_line_white"}, + {"default": true, "material": "center_lines"} + ] + } +} +``` + +`role` 为 `surface` 或 `marking` 时必须有 `materialLayer`;`semantic` 不得有材质要求,Blender 必须跳过其 GeoJSON。`splitBy` 只支持精确属性匹配和一个 default 分支,避免把渲染表达式语言复制进编译器。 + +编译器在原子提升前校验:每个声明 source 都对应 `layers/.geojson`,目录中没有未声明的 GeoJSON;manifest 自身必须是有效 JSON。新增 manifest 不改变已有文件内容。 + +## Blender Adapter + +新建 `blender/osmassets/native_roads.py`,公开一个接收 `native_road_dir`, `projector`, `collection`, `road_mats`, `material_layers` 的装配函数。模块负责读取/校验 manifest、解析 split cases、调用既有 `roads.assemble_geojson_layer`;不拥有图层注册表、不创建材质、不维护场景总计数。 + +`generate_scene.py` 仅负责 native-road 目录存在性、catalog material lookup、调用 adapter 和合并 `road_counts`。删除 `catalog.NATIVE_ROAD_LAYERS`;保留 `catalog.ROAD_LAYERS` 作为宿主材质和 z 高度事实源。 + +未知 `materialLayer`、缺少 source 文件、重复/遗漏 source、非法 role 或非法 splitBy 必须抛出 `RuntimeError`,不能静默跳过。semantic 图层必须不读取 GeoJSON、不创建对象、不增加 road count。 + +## Compatibility and Rollback + +编译器契约从 `native-road-package/v1` 增加到 `v1.1`;manifest 是 v1.1 的必需输出。Blender 不提供旧 manifest fallback,因为新消费边界必须尽早暴露旧 compiler;回滚时恢复 `NATIVE_ROAD_LAYERS` 内联分支即可。旧 v1 产物仍可由旧 compiler tag 生成,不被本阶段改写。 + +## Verification + +- 两个支持区域的 native-road 文件 parity:除新增 `manifest.json` 外所有现有 hash、orderHash、bytes 不变。 +- Blender 结构摘要和预览图与 Phase 0 基线一致;完整 Blender/Metal 运行需提权。 +- 纯 Python schema/manifest 测试覆盖 12 source、semantic skip、splitBy white/yellow、未知材质、缺文件和额外文件。 +- throwaway `debug_probe.geojson` + manifest 实测:不改宿主代码即可生成 Blender 几何;验证后删除 probe 并再次跑 parity。 diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/implement.jsonl b/.trellis/tasks/08-25-rc-p3-render-separation/implement.jsonl index 9dd3234..907b895 100644 --- a/.trellis/tasks/08-25-rc-p3-render-separation/implement.jsonl +++ b/.trellis/tasks/08-25-rc-p3-render-separation/implement.jsonl @@ -1 +1,5 @@ -{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} +{"file":".trellis/spec/blender/module-structure.md","reason":"native_roads.py 必须遵守 osmassets 的 bpy 依赖边界和 roads adapter 责任。"} +{"file":".trellis/spec/blender/testing.md","reason":"Blender 层以结构摘要/parity 为主要回归防线,纯 schema 逻辑需可用系统 Python 测试。"} +{"file":".trellis/spec/pipeline/index.md","reason":"native-road manifest 是跨 compiler/Blender 的 pipeline 文件契约。"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"改动同时触及 compiler JSON、Blender Python 和契约文档。"} +{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"本阶段必须证明除新增 manifest 外渲染产物不变。"} diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/implement.md b/.trellis/tasks/08-25-rc-p3-render-separation/implement.md index 54f7e50..fb03366 100644 --- a/.trellis/tasks/08-25-rc-p3-render-separation/implement.md +++ b/.trellis/tasks/08-25-rc-p3-render-separation/implement.md @@ -4,7 +4,7 @@ - [x] 读 `blender/generate_scene.py:796-834` 全部道路分支 - [x] 列出每个 `source` 的处理方式:直接渲染 / 按属性分流 / 跳过 -- [ ] 已知分流: +- [x] 已知分流: - `center_lines`:`color != "white"` → `center_lines` 材质; `color == "white"` → `native_center_line_white` - `lane_separators`:`color != "yellow"` → `lane_separators`; @@ -16,17 +16,17 @@ ## Step 2 — 定 manifest schema 并落契约 -- [ ] 按父任务 `design.md` §3.2 定稿 schema -- [ ] 写入编译器仓库契约文档,版本标为 `native-road-package/v1.1` -- [ ] 记录:v1.1 起 `manifest.json` 为必需输出 +- [x] 按父任务 `design.md` §3.2 定稿 schema +- [x] 写入编译器仓库契约文档,版本标为 `native-road-package/v1.1` +- [x] 记录:v1.1 起 `manifest.json` 为必需输出 ## Step 3 — 编译器侧:生成 manifest -- [ ] 编译器内建立图层注册表(单一事实源),`compileGeometry` 的输出键与之对应 -- [ ] 写 `manifest.json` 到 outDir -- [ ] 编译器自校验:manifest 声明的每个 `source` 都必须有对应 geojson 文件, +- [x] 编译器内建立图层注册表(单一事实源),`compileGeometry` 的输出键与之对应 +- [x] 写 `manifest.json` 到 outDir +- [x] 编译器自校验:manifest 声明的每个 `source` 都必须有对应 geojson 文件, 反之亦然(缺一即报错,不静默) -- [ ] parity:此步只**新增** manifest.json,其余文件应逐字节不变 +- [x] parity:此步只**新增** manifest.json,其余文件应逐字节不变;hanyang-block 不参与 ```bash # 只应多出 manifest.json @@ -36,14 +36,14 @@ node scripts/road-parity.js --config config/areas/fengshu-er-road.json \ ## Step 4 — Blender 侧:抽模块 + 读 manifest -- [ ] 新建 `blender/osmassets/native_roads.py` -- [ ] 把 `generate_scene.py:796-834` 的道路分支迁入 -- [ ] 改为读 manifest 遍历;`role == "semantic"` 跳过 -- [ ] `splitBy` 通用化实现,替掉两处硬编码 lambda -- [ ] **加断言**:manifest 里的 `materialLayer` 若不在 `catalog.MATERIALS` / +- [x] 新建 `blender/osmassets/native_roads.py` +- [x] 把 `generate_scene.py:796-834` 的道路分支迁入 +- [x] 改为读 manifest 遍历;`role == "semantic"` 跳过 +- [x] `splitBy` 通用化实现,替掉两处硬编码 lambda +- [x] **加断言**:manifest 里的 `materialLayer` 若不在 `catalog.MATERIALS` / `ROAD_LAYERS` 中,直接 raise,不静默跳过(风险表第二条) -- [ ] 删除 `catalog.NATIVE_ROAD_LAYERS` -- [ ] `generate_scene.py` 只保留一行调用 +- [x] 删除 `catalog.NATIVE_ROAD_LAYERS` +- [x] `generate_scene.py` 只保留一行调用 ## Step 5 — 验证 @@ -52,8 +52,8 @@ node scripts/road-parity.js --config config/areas/fengshu-er-road.json \ npm run build:area -- --config config/areas/fengshu-er-road.json # 走 artifact-parity-guide 的结构摘要比对 -# 三区域跑通 -for a in fengshu-er-road hanyang-block nantaizi-lake-innovation-valley; do +# 两个有效区域跑通(hanyang-block 为废案,不参与) +for a in fengshu-er-road nantaizi-lake-innovation-valley; do npm run build:area -- --config config/areas/$a.json || echo "FAIL $a" done @@ -61,18 +61,18 @@ done git diff --stat -- blender/osmassets/ | grep -vE "native_roads|catalog" ``` -- [ ] 渲染预览图人工对照(结构摘要粒度不足的兜底) +- [x] 渲染预览图人工对照(结构摘要粒度不足的兜底) ## Step 6 — AC3.4 实测(可扩展性证明) 这一步是整个 Phase 3 的价值证明,不可跳过。 -- [ ] 编译器加一个 throwaway 图层 `debug_probe.geojson`(几个矩形即可) +- [x] 编译器加一个 throwaway 图层 `debug_probe.geojson`(复用 road-surface 几何仅验证 wiring) + manifest 声明 `role: "marking"`,复用现有材质槽 -- [ ] **不改宿主任何代码**,跑 blender 阶段 -- [ ] 确认 Blender 场景中出现该图层几何 -- [ ] 截图/记录证据到任务 `research/` -- [ ] 回滚 throwaway 图层 +- [x] **不改宿主任何代码**,跑 blender 阶段 +- [x] 确认 Blender 场景中出现该图层几何 +- [x] 截图/记录证据到任务 `research/` +- [x] 回滚 throwaway 图层 ## Review Gate diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/prd.md b/.trellis/tasks/08-25-rc-p3-render-separation/prd.md index adca43d..73ac8ff 100644 --- a/.trellis/tasks/08-25-rc-p3-render-separation/prd.md +++ b/.trellis/tasks/08-25-rc-p3-render-separation/prd.md @@ -57,17 +57,17 @@ ## Acceptance Criteria -- [ ] AC3.1 编译器输出 `manifest.json`,三区域均含全部 12 图层声明 -- [ ] AC3.2 `catalog.NATIVE_ROAD_LAYERS` 已删除,Blender 无硬编码道路图层表 -- [ ] AC3.3 `.blend` 结构摘要对 Phase 0 基线一致 +- [x] AC3.1 编译器输出 `manifest.json`,两个有效区域(`fengshu-er-road`、`nantaizi-lake-innovation-valley`)均含全部 12 图层声明 +- [x] AC3.2 `catalog.NATIVE_ROAD_LAYERS` 已删除,Blender 无硬编码道路图层表 +- [x] AC3.3 `.blend` 结构摘要对 Phase 0 基线一致 (走 `.trellis/spec/guides/artifact-parity-guide.md`) -- [ ] AC3.4 **实测**:向编译器加一个 throwaway 图层 + manifest 声明, +- [x] AC3.4 **实测**:向编译器加一个 throwaway 图层 + manifest 声明, 不改宿主任何代码,跑 blender 阶段确认它被渲染出来;验证后回滚该图层 (父任务 AC5,见 design §3.4) -- [ ] AC3.5 `role: "semantic"` 的图层被 Blender 正确跳过(不产生几何) -- [ ] AC3.6 `center_lines` 的 white/非 white 与 `lane_separators` 的 yellow/非 yellow +- [x] AC3.5 `role: "semantic"` 的图层被 Blender 正确跳过(不产生几何) +- [x] AC3.6 `center_lines` 的 white/非 white 与 `lane_separators` 的 yellow/非 yellow 分流行为与改造前一致 -- [ ] AC3.7 建筑/植被/水体渲染代码未被修改(`git diff` 验证范围) +- [x] AC3.7 建筑/植被/水体渲染代码未被修改(`git diff` 验证范围) ## 依赖与顺序 diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/research/debug-probe-evidence.md b/.trellis/tasks/08-25-rc-p3-render-separation/research/debug-probe-evidence.md new file mode 100644 index 0000000..70095bc --- /dev/null +++ b/.trellis/tasks/08-25-rc-p3-render-separation/research/debug-probe-evidence.md @@ -0,0 +1,19 @@ +# P3 Debug Probe Evidence + +Date: 2026-08-26 + +A temporary, uncommitted compiler registry entry declared `debug_probe` as a +`marking` layer using the existing `road_surface` material slot. Its generated +GeoJSON reused the compiled road-surface collection only for this wiring test. +No host source was changed. + +The compiler emitted 13 declared sources. Blender then generated +`/private/tmp/p3-debug-probe.blend`; `SCENE_DONE` reported +`"debug_probe": 30`. The Blender scene digest contains: + +```json +{"name":"Road_debug_probe","vertices":274,"polygons":30} +``` + +The temporary registry entry was removed immediately after the proof. The +standard v0.2.0 compiler was then rerun to restore the normal 12-source output. diff --git a/.trellis/tasks/08-25-rc-p3-render-separation/task.json b/.trellis/tasks/08-25-rc-p3-render-separation/task.json index 294f1ed..a0497f1 100644 --- a/.trellis/tasks/08-25-rc-p3-render-separation/task.json +++ b/.trellis/tasks/08-25-rc-p3-render-separation/task.json @@ -3,7 +3,7 @@ "name": "rc-p3-render-separation", "title": "Phase 3:道路与建筑渲染分离", "description": "编译器输出 layer manifest 自声明图层,Blender 改为读 manifest;道路渲染从 generate_scene.py 抽离,消除跨仓库重复图层表", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/blender/generate_scene.py b/blender/generate_scene.py index a337af5..b0ab30b 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -57,6 +57,7 @@ from osmassets import fountain as _fountain # noqa: E402 from osmassets import water as _water # noqa: E402 from osmassets import grass as _grass # noqa: E402 from osmassets import roads as _roads # noqa: E402 +from osmassets import native_roads as _native_roads # noqa: E402 from osmassets import scrub as _scrub # noqa: E402 from osmassets import tree as _tree # noqa: E402 from osmassets import traffic_signals as _traffic_signals # noqa: E402 @@ -798,30 +799,9 @@ def build(args): if native_road_dir: if not os.path.isdir(native_road_dir): raise RuntimeError("--native-road directory does not exist: " + native_road_dir) - material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS} - for source in catalog.NATIVE_ROAD_LAYERS: - target = source["material_layer"] - layer = material_layers[target] - source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson") - if not os.path.isfile(source_path): - raise RuntimeError("Native road layer is missing: " + source_path) - if source["source"] == "center_lines": - count = _roads.assemble_geojson_layer( - source_path, source["source"], projector, roads_c, - road_mats[target], layer["z"], - lambda props: props.get("color") != "white") - count += _roads.assemble_geojson_layer( - source_path, source["source"] + "_white", projector, roads_c, - road_mats["native_center_line_white"], layer["z"], - lambda props: props.get("color") == "white") - elif source["source"] == "lane_separators": - count = _roads.assemble_geojson_layer(source_path, source["source"], projector, roads_c, road_mats[target], layer["z"], lambda props: props.get("color") != "yellow") - count += _roads.assemble_geojson_layer(source_path, source["source"] + "_yellow", projector, roads_c, road_mats["native_lane_separator_yellow"], layer["z"], lambda props: props.get("color") == "yellow") - else: - count = _roads.assemble_geojson_layer( - source_path, source["source"], projector, roads_c, - road_mats[target], layer["z"]) - road_counts[source["source"]] = count + material_layers = {layer["id"]: road_mats.get(layer["id"]) for layer in catalog.ROAD_LAYERS} + material_layers.update({"native_center_line_white": road_mats["native_center_line_white"], "native_lane_separator_yellow": road_mats["native_lane_separator_yellow"]}) + road_counts.update(_native_roads.assemble(native_road_dir, projector, roads_c, material_layers)) elif geojson_dir and os.path.isdir(geojson_dir): for problem in catalog.check_layers(geojson_dir): print("Layer catalog warning:", problem) diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py index b6800f2..bb5e459 100644 --- a/blender/osmassets/catalog.py +++ b/blender/osmassets/catalog.py @@ -50,20 +50,6 @@ SCENE_STYLE_FILE = "osm2streets_scene_style.json" # Native-road output intentionally maps into existing scene material layers. # It is a provider adapter, not a second scene-layer registry. -NATIVE_ROAD_LAYERS = ( - {"source": "road_surface", "material_layer": "road_surface"}, - {"source": "edge_lines", "material_layer": "lane_separators"}, - {"source": "intersection_surface", "material_layer": "intersection_surface"}, - {"source": "sidewalk_surface", "material_layer": "sidewalks"}, - {"source": "lane_separators", "material_layer": "lane_separators"}, - {"source": "center_lines", "material_layer": "center_lines"}, - {"source": "direction_arrows", "material_layer": "lane_arrows_webscale"}, - {"source": "turn_arrows", "material_layer": "lane_arrows_webscale"}, - {"source": "crosswalks", "material_layer": "crosswalks"}, - {"source": "vehicle_stop_lines", "material_layer": "vehicle_stop_lines"}, -) - - # Material specs. `kind` selects the builder: # solid — flat base colour # textured — Poly Haven diffuse + normal, optionally tinted diff --git a/blender/osmassets/native_road_manifest.py b/blender/osmassets/native_road_manifest.py new file mode 100644 index 0000000..4f567ef --- /dev/null +++ b/blender/osmassets/native_road_manifest.py @@ -0,0 +1,95 @@ +"""Pure-Python validation for native-road package manifests.""" + +import json +import os + + +CONTRACT = "native-road-package/v1.1" +RENDERABLE_ROLES = ("surface", "marking") +VALID_ROLES = RENDERABLE_ROLES + ("semantic",) + + +def _error(message): + raise RuntimeError("Invalid native road manifest: " + message) + + +def _source_name(value): + return (isinstance(value, str) and value + and value == os.path.basename(value) + and value not in (".", "..")) + + +def _validate_split(source, split): + if not isinstance(split, dict): + _error("layer '%s' splitBy must be an object" % source) + prop = split.get("prop") + cases = split.get("cases") + if not isinstance(prop, str) or not prop or not isinstance(cases, list) or not cases: + _error("layer '%s' has an invalid splitBy" % source) + + matches = set() + defaults = 0 + for case in cases: + if not isinstance(case, dict) or not isinstance(case.get("material"), str): + _error("layer '%s' has an invalid splitBy case" % source) + is_default = case.get("default") is True + has_match = "match" in case + if is_default == has_match: + _error("layer '%s' splitBy cases need exactly one of match/default" % source) + if is_default: + defaults += 1 + else: + match = case["match"] + if match in matches: + _error("layer '%s' has a duplicate splitBy match" % source) + matches.add(match) + if defaults != 1: + _error("layer '%s' splitBy needs exactly one default case" % source) + + +def load(native_road_dir): + """Load a v1.1 manifest and ensure it declares exactly its GeoJSON files.""" + manifest_path = os.path.join(native_road_dir, "manifest.json") + if not os.path.isfile(manifest_path): + raise RuntimeError("Native road manifest is missing: " + manifest_path) + try: + with open(manifest_path, "r", encoding="utf-8") as handle: + manifest = json.load(handle) + except (OSError, ValueError) as error: + raise RuntimeError("Could not read native road manifest: " + str(error)) from error + + if not isinstance(manifest, dict) or manifest.get("contract") != CONTRACT: + _error("unsupported contract") + layers = manifest.get("layers") + if not isinstance(layers, list) or not layers: + _error("layers must be a non-empty array") + + declared = set() + for spec in layers: + if not isinstance(spec, dict): + _error("layer entries must be objects") + source = spec.get("source") + role = spec.get("role") + if not _source_name(source) or source in declared or role not in VALID_ROLES: + _error("layer source or role is invalid") + declared.add(source) + if role in RENDERABLE_ROLES: + if not isinstance(spec.get("materialLayer"), str) or not spec["materialLayer"]: + _error("renderable layer '%s' needs materialLayer" % source) + if "splitBy" in spec: + _validate_split(source, spec["splitBy"]) + elif "materialLayer" in spec or "splitBy" in spec: + _error("semantic layer '%s' must not declare rendering" % source) + + layers_dir = os.path.join(native_road_dir, "layers") + if not os.path.isdir(layers_dir): + raise RuntimeError("Native road layers directory is missing: " + layers_dir) + published = { + name[:-8] for name in os.listdir(layers_dir) + if name.endswith(".geojson") and os.path.isfile(os.path.join(layers_dir, name)) + } + if published != declared: + missing = sorted(declared - published) + extra = sorted(published - declared) + _error("declared GeoJSON mismatch (missing=%s, extra=%s)" % (missing, extra)) + return manifest diff --git a/blender/osmassets/native_roads.py b/blender/osmassets/native_roads.py new file mode 100644 index 0000000..ef6590a --- /dev/null +++ b/blender/osmassets/native_roads.py @@ -0,0 +1,51 @@ +"""Manifest-driven native road layer adapter.""" + +import os + +from osmassets import catalog +from osmassets import native_road_manifest +from osmassets import roads + + +def _material(material_layers, name): + if name not in material_layers: + raise RuntimeError("Native road manifest references unknown materialLayer: " + name) + return material_layers[name] + + +def _split_filter(split, case): + if not split: + return None + prop = split["prop"] + if "match" in case: + return lambda props: props.get(prop) == case["match"] + matches = {item["match"] for item in split["cases"] if "match" in item} + return lambda props: props.get(prop) not in matches + + +def assemble(native_road_dir, projector, collection, material_layers): + manifest = native_road_manifest.load(native_road_dir) + z_by_material_layer = {layer["id"]: layer["z"] for layer in catalog.ROAD_LAYERS} + counts = {} + for spec in manifest["layers"]: + source = spec["source"] + role = spec["role"] + source_path = os.path.join(native_road_dir, "layers", source + ".geojson") + if role == "semantic": + continue + material_name = spec["materialLayer"] + if material_name not in z_by_material_layer: + raise RuntimeError("Native road manifest references unknown materialLayer: " + material_name) + _material(material_layers, material_name) + count = 0 + split = spec.get("splitBy") + cases = split.get("cases", []) if split else [{"default": True, "material": material_name}] + for case in cases: + case_material_name = case.get("material", material_name) + case_material = _material(material_layers, case_material_name) + suffix = "_" + str(case["match"]) if "match" in case else "" + count += roads.assemble_geojson_layer( + source_path, source + suffix, projector, collection, case_material, + z_by_material_layer[material_name], _split_filter(split, case)) + counts[source] = count + return counts diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py index 134051f..67be141 100644 --- a/blender/tests/test_pure.py +++ b/blender/tests/test_pure.py @@ -10,6 +10,7 @@ The expected values are derived from the geometry, not captured from the implementation — a test that just records current output would ratify a bug. """ +import json import math import os import sys @@ -30,7 +31,8 @@ from osmassets.geom import ( sample_tree_row, signed_polygon_area, ) -from osmassets.catalog import NATIVE_ROAD_LAYERS, ROAD_LAYERS +from osmassets.catalog import ROAD_LAYERS +from osmassets.native_road_manifest import CONTRACT, load as load_native_road_manifest from osmassets.osm import Projector, parse_height, parse_osm, tags @@ -46,24 +48,6 @@ class RoadLayerCatalogTest(unittest.TestCase): self.assertNotEqual(layers["road_surface"]["z"], layers["intersection_surface"]["z"]) - def test_native_provider_maps_to_existing_material_layers(self): - layers = {layer["id"] for layer in ROAD_LAYERS} - self.assertEqual( - [(entry["source"], entry["material_layer"]) - for entry in NATIVE_ROAD_LAYERS], - [("road_surface", "road_surface"), - ("edge_lines", "lane_separators"), - ("intersection_surface", "intersection_surface"), - ("sidewalk_surface", "sidewalks"), - ("lane_separators", "lane_separators"), - ("center_lines", "center_lines"), - ("direction_arrows", "lane_arrows_webscale"), - ("turn_arrows", "lane_arrows_webscale"), - ("crosswalks", "crosswalks"), - ("vehicle_stop_lines", "vehicle_stop_lines")]) - self.assertTrue(all(entry["material_layer"] in layers - for entry in NATIVE_ROAD_LAYERS)) - def test_cesium_export_keeps_signal_assets_optional(self): exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "export_cesium.py") @@ -72,6 +56,66 @@ class RoadLayerCatalogTest(unittest.TestCase): self.assertIn('if not args.get(key):\n continue', source) +class NativeRoadManifestTest(unittest.TestCase): + def setUp(self): + self.root = tempfile.TemporaryDirectory() + self.layers_dir = os.path.join(self.root.name, "layers") + os.mkdir(self.layers_dir) + + def tearDown(self): + self.root.cleanup() + + def write_manifest(self, layers): + with open(os.path.join(self.root.name, "manifest.json"), "w", encoding="utf-8") as handle: + json.dump({"contract": CONTRACT, "areaId": "test", "layers": layers}, handle) + for layer in layers: + with open(os.path.join(self.layers_dir, layer["source"] + ".geojson"), "w", + encoding="utf-8") as handle: + json.dump({"type": "FeatureCollection", "features": []}, handle) + + def test_accepts_semantic_and_split_layers(self): + layers = [ + {"source": "road_surface", "role": "surface", "materialLayer": "road_surface"}, + {"source": "center_lines", "role": "marking", "materialLayer": "center_lines", + "splitBy": {"prop": "color", "cases": [ + {"match": "white", "material": "native_center_line_white"}, + {"default": True, "material": "center_lines"}, + ]}}, + {"source": "connectors", "role": "semantic"}, + ] + self.write_manifest(layers) + self.assertEqual(load_native_road_manifest(self.root.name)["layers"], layers) + + def test_rejects_undeclared_or_missing_geojson(self): + layers = [{"source": "road_surface", "role": "surface", + "materialLayer": "road_surface"}] + self.write_manifest(layers) + with open(os.path.join(self.layers_dir, "extra.geojson"), "w", encoding="utf-8") as handle: + handle.write("{}") + with self.assertRaisesRegex(RuntimeError, "mismatch"): + load_native_road_manifest(self.root.name) + os.unlink(os.path.join(self.layers_dir, "extra.geojson")) + os.unlink(os.path.join(self.layers_dir, "road_surface.geojson")) + with self.assertRaisesRegex(RuntimeError, "mismatch"): + load_native_road_manifest(self.root.name) + + def test_rejects_invalid_semantic_and_split_definitions(self): + self.write_manifest([{"source": "connectors", "role": "semantic", + "materialLayer": "road_surface"}]) + with self.assertRaisesRegex(RuntimeError, "semantic"): + load_native_road_manifest(self.root.name) + + self.write_manifest([{"source": "center_lines", "role": "marking", + "materialLayer": "center_lines", "splitBy": { + "prop": "color", "cases": [ + {"match": "white", "material": "native_center_line_white"}, + {"default": True, "material": "center_lines"}, + {"default": True, "material": "center_lines"}, + ]}}]) + with self.assertRaisesRegex(RuntimeError, "exactly one default"): + load_native_road_manifest(self.root.name) + + class GeometryRingsTest(unittest.TestCase): def test_polygon_keeps_only_the_exterior_ring(self): geometry = {"type": "Polygon", "coordinates": [["outer"], ["hole"]]} diff --git a/docs/native-road-package-v1.md b/docs/native-road-package-v1.md index b508157..9049b46 100644 --- a/docs/native-road-package-v1.md +++ b/docs/native-road-package-v1.md @@ -1,6 +1,6 @@ -# Native Road Package v1 +# Native Road Package v1.1 -`native-road-package/v1` defines the boundary between the host area pipeline +`native-road-package/v1.1` defines the boundary between the host area pipeline and the native road compiler. The compiler accepts only the input below; it does not read `config/areas/*.json` or derive host paths. @@ -60,6 +60,14 @@ shape and owns all path derivation. 11. `lane_centerlines.geojson` (semantic, not rendered) 12. `connectors.geojson` (semantic, not rendered) +`manifest.json` is required. It declares the same twelve sources in published +order using `{ source, role, materialLayer?, splitBy? }`. `surface` and +`marking` layers declare a host material slot; `semantic` layers are published +but Blender must not read or render them. A `splitBy` entry has one exact-match +case or more plus exactly one default case, so the legacy white center-line and +yellow lane-separator material routing remains declarative. Consumers reject a +missing/invalid manifest, a source/file mismatch, and unknown material slots. + The editable signal source at `trafficSignalsFile` is a sibling of `outDir`. It is included in the parity baseline because regeneration must be stable. diff --git a/package-lock.json b/package-lock.json index 99fea7f..3cdc36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "dependencies": { "@inquirer/prompts": "^8.5.2", - "@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.1.2", + "@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.2.2", "ol": "^10.10.0", "osm2streets-js-node": "0.1.4" } @@ -343,8 +343,8 @@ } }, "node_modules/@osm-asset/road-compiler": { - "version": "0.1.0", - "resolved": "git+https://git.app.que01.top/que01/road-compiler.git#90400d967f9be6ad6420b8b29e5d7d652eee3493", + "version": "0.2.2", + "resolved": "git+https://git.app.que01.top/que01/road-compiler.git#3b4befb02550c524ae83831e77959423e3bb40bf", "dependencies": { "ol": "10.10.0" }, diff --git a/package.json b/package.json index 07896cd..398af2f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.5.2", - "@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.1.2", + "@osm-asset/road-compiler": "git+https://git.app.que01.top/que01/road-compiler.git#v0.2.2", "ol": "^10.10.0", "osm2streets-js-node": "0.1.4" }