feat: consume manifest-driven native roads
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
95
blender/osmassets/native_road_manifest.py
Normal file
95
blender/osmassets/native_road_manifest.py
Normal 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
|
||||
51
blender/osmassets/native_roads.py
Normal file
51
blender/osmassets/native_roads.py
Normal 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
|
||||
@@ -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"]]}
|
||||
|
||||
Reference in New Issue
Block a user