244 lines
14 KiB
Python
244 lines
14 KiB
Python
"""Single source of truth for the scene's road layers and materials.
|
|
|
|
Before this module the same facts lived in several places at once: the nine
|
|
osm2streets layers had their draw order in `scripts/lib/scene-layers.js`, their
|
|
Blender heights in a `layer_z` dict, and their colours in a `road_mats` dict —
|
|
three copies across two languages, kept in sync by hand. Everything the scene
|
|
builder needs is now declared here, once.
|
|
|
|
Two deliberate non-goals:
|
|
|
|
- The colours here are NOT derived from `scene-layers.js`. That file's `fill`
|
|
values are QGIS sRGB hex for a 2D debug map; these are linear Blender base
|
|
colours for a 3D scene, and the two were tuned separately. `check_layers`
|
|
cross-checks the layer *set and order* — the part that must agree — and
|
|
leaves the palettes alone.
|
|
- Order is load-bearing. Material creation order fixes the material indices in
|
|
the exported GLB, and layer order fixes mesh creation order, so both lists
|
|
are sequences, not dicts, and appending is the only safe edit.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
|
|
# Draw order, bottom first. `z` is the Blender height in metres that keeps the
|
|
# markings above the asphalt without z-fighting; `id` matches the GeoJSON file
|
|
# stem written by the intermediates stage.
|
|
ROAD_LAYERS = [
|
|
{"id": "road_surface", "material": "Road Asphalt",
|
|
"color": (0.055, 0.065, 0.070), "z": 0.03},
|
|
{"id": "intersection_surface", "material": "Intersection Asphalt",
|
|
"color": (0.055, 0.065, 0.070), "z": 0.035},
|
|
{"id": "sidewalks", "material": "Sidewalk",
|
|
"color": (0.49, 0.51, 0.49), "z": 0.065},
|
|
{"id": "sidewalk_corners", "material": "Sidewalk Corner",
|
|
"color": (0.49, 0.51, 0.49), "z": 0.067},
|
|
{"id": "lane_separators", "material": "Lane Separator",
|
|
"color": (0.85, 0.84, 0.72), "z": 0.090},
|
|
{"id": "center_lines", "material": "Center Line",
|
|
"color": (0.94, 0.58, 0.06), "z": 0.092},
|
|
{"id": "crosswalks", "material": "Crosswalk",
|
|
"color": (0.95, 0.94, 0.82), "z": 0.094},
|
|
{"id": "vehicle_stop_lines", "material": "Stop Line",
|
|
"color": (0.95, 0.94, 0.82), "z": 0.096},
|
|
{"id": "lane_arrows_webscale", "material": "Lane Arrow",
|
|
"color": (0.95, 0.94, 0.82), "z": 0.098},
|
|
]
|
|
|
|
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": "intersection_surface", "material_layer": "intersection_surface"},
|
|
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
|
|
{"source": "lane_separators", "material_layer": "lane_separators"},
|
|
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
|
|
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
|
|
)
|
|
|
|
|
|
# Material specs. `kind` selects the builder:
|
|
# solid — flat base colour
|
|
# textured — Poly Haven diffuse + normal, optionally tinted
|
|
# `procedural` adds noise-driven base colour and bump on top of a solid.
|
|
MATERIALS = {
|
|
"ground": {"kind": "solid", "name": "Ground", "color": (0.27, 0.32, 0.24)},
|
|
"water": {"kind": "solid", "name": "Lake Water", "color": (0.035, 0.22, 0.30),
|
|
"roughness": 0.18, "metallic": 0.05},
|
|
"grass": {"kind": "textured", "name": "Grass",
|
|
"diffuse": "leafy_grass_diff_1k.jpg",
|
|
"normal": "leafy_grass_nor_gl_1k.jpg",
|
|
"roughness": 0.92, "scale": 7.0,
|
|
"tint": (0.12, 0.48, 0.08), "tint_factor": 0.72,
|
|
"cesium": {"tint": ((0.12, 0.48, 0.08), 0.72)}},
|
|
"scrub": {"kind": "textured", "name": "Scrub Ground Cover",
|
|
"diffuse": "leafy_grass_diff_1k.jpg",
|
|
"normal": "leafy_grass_nor_gl_1k.jpg",
|
|
"roughness": 0.96, "scale": 15.0,
|
|
"tint": (0.085, 0.30, 0.065), "tint_factor": 0.46,
|
|
"cesium": {"tint": ((0.08, 0.28, 0.07), 0.28)}},
|
|
|
|
"fountain_stone": {"kind": "solid", "name": "Fountain Stone",
|
|
"color": (0.42, 0.45, 0.43), "roughness": 0.72},
|
|
"fountain_water": {"kind": "solid", "name": "Fountain Water",
|
|
"color": (0.03, 0.32, 0.42), "roughness": 0.16,
|
|
"metallic": 0.05},
|
|
"fountain_spray": {"kind": "solid", "name": "Fountain Spray",
|
|
"color": (0.20, 0.70, 0.78), "roughness": 0.12,
|
|
"metallic": 0.02},
|
|
|
|
"building_default": {"kind": "textured", "name": "Office White Plaster Facade",
|
|
"diffuse": "white_plaster_02_diff_1k.jpg",
|
|
"normal": "white_plaster_02_nor_gl_1k.jpg",
|
|
"roughness": 0.82, "scale": 4.2, "metallic": 0.0,
|
|
"tint": (0.92, 0.94, 0.92), "tint_factor": 0.38,
|
|
"cesium": {"tint": ((0.92, 0.94, 0.92), 0.38),
|
|
"metallic": 0.0,
|
|
"base_color": (0.93, 0.94, 0.91),
|
|
"emission": ((0.93, 0.94, 0.91), 0.18)}},
|
|
"building_industrial": {"kind": "textured",
|
|
"name": "Industrial White Ribbed Facade",
|
|
"diffuse": "corrugated_iron_03_diff_1k.jpg",
|
|
"normal": "corrugated_iron_03_nor_gl_1k.jpg",
|
|
"roughness": 0.56, "scale": 2.4, "metallic": 0.16,
|
|
"tint": (0.86, 0.92, 0.94), "tint_factor": 0.68,
|
|
"cesium": {"tint": ((0.90, 0.93, 0.91), 0.86),
|
|
"metallic": 0.08,
|
|
"emission": ((0.90, 0.93, 0.91), 0.18)}},
|
|
"building_office_roof": {"kind": "textured", "name": "Office Light Flat Roof",
|
|
"diffuse": "concrete_floor_02_diff_1k.jpg",
|
|
"normal": "concrete_floor_02_bump_1k.jpg",
|
|
"roughness": 0.84, "scale": 5.0,
|
|
"normal_is_bump": True,
|
|
"tint": (0.82, 0.86, 0.88), "tint_factor": 0.35,
|
|
"cesium": {"tint": ((0.82, 0.86, 0.88), 0.35),
|
|
"base_color": (0.88, 0.90, 0.88),
|
|
"emission": ((0.88, 0.90, 0.88), 0.14)}},
|
|
"building_industrial_roof": {"kind": "textured",
|
|
"name": "Factory Blue Metal Roof",
|
|
"diffuse": "blue_metal_plate_diff_1k.jpg",
|
|
"normal": "blue_metal_plate_nor_gl_1k.jpg",
|
|
"roughness": 0.48, "scale": 3.4, "metallic": 0.28,
|
|
"tint": (0.03, 0.42, 0.78), "tint_factor": 0.45,
|
|
"cesium": {"tint": ((0.08, 0.50, 0.88), 0.58),
|
|
"emission": ((0.08, 0.50, 0.88), 0.12)}},
|
|
"building_glass": {"kind": "solid", "name": "Office Blue Gray Glass",
|
|
"color": (0.12, 0.20, 0.24), "roughness": 0.22,
|
|
"metallic": 0.10},
|
|
"building_factory_glass": {"kind": "solid", "name": "Factory Dark Windows",
|
|
"color": (0.10, 0.14, 0.15), "roughness": 0.28,
|
|
"metallic": 0.08},
|
|
|
|
"tree_trunk": {"kind": "textured", "name": "Tree Trunk",
|
|
"diffuse": "bark_brown_01_diff_1k.jpg",
|
|
"normal": "bark_brown_01_nor_gl_1k.jpg",
|
|
"roughness": 0.92, "scale": 5.0},
|
|
"tree_crown_dark": {"kind": "solid", "name": "Tree Crown Dark",
|
|
"color": (0.065, 0.25, 0.055), "roughness": 0.90,
|
|
"procedural": {"colors": ((0.035, 0.14, 0.035),
|
|
(0.12, 0.36, 0.08)),
|
|
"scale": 3.2, "detail": 3.8,
|
|
"bump_strength": 0.08},
|
|
"cesium": {"tint": ((0.06, 0.22, 0.05), 0.18),
|
|
"base_color": (0.065, 0.24, 0.055),
|
|
"emission": ((0.025, 0.07, 0.02), 0.015)}},
|
|
"tree_crown_light": {"kind": "solid", "name": "Tree Crown Light",
|
|
"color": (0.13, 0.42, 0.09), "roughness": 0.88,
|
|
"procedural": {"colors": ((0.07, 0.25, 0.05),
|
|
(0.22, 0.56, 0.13)),
|
|
"scale": 3.6, "detail": 3.4,
|
|
"bump_strength": 0.07},
|
|
"cesium": {"tint": ((0.16, 0.42, 0.09), 0.16),
|
|
"base_color": (0.14, 0.40, 0.085),
|
|
"emission": ((0.045, 0.12, 0.03), 0.015)}},
|
|
"tree_crown": {"kind": "solid", "name": "Tree Crown",
|
|
"color": (0.10, 0.36, 0.08), "roughness": 0.88,
|
|
"procedural": {"colors": ((0.04, 0.18, 0.04),
|
|
(0.18, 0.50, 0.12)),
|
|
"scale": 2.8, "detail": 3.2,
|
|
"bump_strength": 0.10},
|
|
"cesium": {"tint": None,
|
|
"base_color": (0.11, 0.34, 0.075),
|
|
"emission": ((0.04, 0.11, 0.035), 0.02)}},
|
|
"traffic_signal_metal": {"kind": "solid", "name": "Traffic Signal Metal",
|
|
"color": (0.045, 0.065, 0.075), "roughness": 0.42,
|
|
"metallic": 0.62,
|
|
"cesium": {"base_color": (0.12, 0.16, 0.18),
|
|
"metallic": 0.42,
|
|
"emission": ((0.035, 0.05, 0.06), 0.03)}},
|
|
"traffic_signal_housing": {"kind": "solid", "name": "Traffic Signal Housing",
|
|
"color": (0.02, 0.03, 0.035), "roughness": 0.54,
|
|
"metallic": 0.12,
|
|
"cesium": {"base_color": (0.055, 0.075, 0.085),
|
|
"emission": ((0.018, 0.025, 0.03), 0.025)}},
|
|
# Static lenses are intentionally neutral and dark. The separate dynamic
|
|
# GLB is the sole source of phase colour, so inactive red/yellow/green
|
|
# glass cannot visually mask an otherwise working phase transition.
|
|
"traffic_signal_red": {"kind": "solid", "name": "Traffic Signal Red Lens",
|
|
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
|
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
|
"traffic_signal_yellow": {"kind": "solid", "name": "Traffic Signal Yellow Lens",
|
|
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
|
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
|
"traffic_signal_green": {"kind": "solid", "name": "Traffic Signal Green Lens",
|
|
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
|
|
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
|
|
"traffic_signal_active_red": {"kind": "solid", "name": "Traffic Signal Active Red",
|
|
"color": (0.93, 0.05, 0.035), "roughness": 0.25,
|
|
"cesium": {"base_color": (0.93, 0.05, 0.035),
|
|
"emission": ((0.93, 0.05, 0.035), 1.0)}},
|
|
"traffic_signal_active_yellow": {"kind": "solid", "name": "Traffic Signal Active Yellow",
|
|
"color": (0.98, 0.63, 0.03), "roughness": 0.25,
|
|
"cesium": {"base_color": (0.98, 0.63, 0.03),
|
|
"emission": ((0.98, 0.63, 0.03), 1.0)}},
|
|
"traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green",
|
|
"color": (0.04, 0.82, 0.22), "roughness": 0.25,
|
|
"cesium": {"base_color": (0.04, 0.82, 0.22),
|
|
"emission": ((0.04, 0.82, 0.22), 1.0)}},
|
|
}
|
|
|
|
|
|
def road_material_specs():
|
|
"""Road layer materials as MATERIALS-shaped specs, in draw order."""
|
|
return [{"kind": "solid", "name": layer["material"], "color": layer["color"]}
|
|
for layer in ROAD_LAYERS]
|
|
|
|
|
|
def check_layers(geojson_dir):
|
|
"""Warn when the intermediates stage and this catalog disagree on layers.
|
|
|
|
The style JSON is written next to the GeoJSON by the intermediates and
|
|
reimport stages. A layer added on the JS side but not here would be
|
|
silently dropped from the 3D scene, which is exactly the kind of drift the
|
|
single-source-of-truth split is meant to make loud. Warn rather than fail:
|
|
a stale or absent output directory should not block a rebuild.
|
|
"""
|
|
style_path = os.path.join(geojson_dir or "", SCENE_STYLE_FILE)
|
|
if not geojson_dir or not os.path.exists(style_path):
|
|
return []
|
|
try:
|
|
with open(style_path, "r", encoding="utf-8") as handle:
|
|
style = json.load(handle)
|
|
except (OSError, ValueError) as error:
|
|
return ["Could not read %s: %s" % (style_path, error)]
|
|
|
|
upstream = [entry.get("id") for entry in style.get("layers", [])]
|
|
local = [layer["id"] for layer in ROAD_LAYERS]
|
|
problems = []
|
|
for missing in [i for i in upstream if i not in local]:
|
|
problems.append(
|
|
"layer '%s' exists in %s but not in catalog.ROAD_LAYERS "
|
|
"(it will not reach the 3D scene)" % (missing, SCENE_STYLE_FILE))
|
|
for extra in [i for i in local if i not in upstream]:
|
|
problems.append(
|
|
"layer '%s' is in catalog.ROAD_LAYERS but not in %s "
|
|
"(no GeoJSON will be produced for it)" % (extra, SCENE_STYLE_FILE))
|
|
if not problems and upstream != local:
|
|
problems.append(
|
|
"layer draw order differs: %s produces %s, catalog stacks %s"
|
|
% (SCENE_STYLE_FILE, upstream, local))
|
|
return problems
|