Files
osmWorkflow/blender/osmassets/catalog.py
que01 24e02e2041 refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)
generate_scene.py 从 1271 → 816 行 (-455)

P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)

P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本

P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典

P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)

校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 17:59:32 +08:00

196 lines
9.9 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.065, 0.075, 0.080), "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"
# 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},
"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},
"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},
"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},
"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},
"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},
"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)}},
"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)}},
"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)}},
}
# Cesium-specific overrides that don't have a home in the material system yet:
# metallic overrides (flat values, not materials) and emission overrides for
# colours that export_cesium.py hand-tuned separately.
CESIUM_EXPORT = {
"metallic_overrides": {
"Office White Plaster Facade": 0.0,
"Industrial White Ribbed Facade": 0.08,
},
"emission_overrides": {
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.18),
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.12),
},
}
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