feat: consume manifest-driven native roads

This commit is contained in:
2026-08-26 10:55:47 +08:00
parent 5704b51315
commit 620127031e
16 changed files with 349 additions and 97 deletions

View File

@@ -22,7 +22,7 @@ road-compiler --input <RoadCompilerInput.json>
## 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

View File

@@ -1 +1,5 @@
{"_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."}
{"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 图层验证后的产物差异。"}

View File

@@ -0,0 +1,61 @@
# Phase 3Manifest 驱动的道路渲染设计
## Boundary
道路编译器是图层事实源,输出 `<nativeRoadDir>/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/<source>.geojson`,目录中没有未声明的 GeoJSONmanifest 自身必须是有效 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。

View File

@@ -1 +1,5 @@
{"_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."}
{"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 外渲染产物不变。"}

View File

@@ -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

View File

@@ -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` 验证范围)
## 依赖与顺序

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]]}

View File

@@ -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.

6
package-lock.json generated
View File

@@ -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"
},

View File

@@ -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"
}