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

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