Files
osmWorkflow/blender/osmassets/catalog.py

241 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"},
)
# 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