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>
This commit is contained in:
12
blender/osmassets/__init__.py
Normal file
12
blender/osmassets/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Reusable pieces of the OSM → Blender/Cesium asset pipeline.
|
||||
|
||||
The package is split by dependency, not by feature:
|
||||
|
||||
- `osm` and `geom` are pure Python. They import no `bpy` and can be run and
|
||||
tested with a plain interpreter (`python3 -m unittest discover blender/tests`).
|
||||
- everything else may touch `bpy` and only runs inside Blender.
|
||||
|
||||
Keeping that line sharp is what makes the geometry testable at all; before the
|
||||
split it was interleaved with scene construction and could only be exercised by
|
||||
rendering a whole area.
|
||||
"""
|
||||
195
blender/osmassets/catalog.py
Normal file
195
blender/osmassets/catalog.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
148
blender/osmassets/geom.py
Normal file
148
blender/osmassets/geom.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Planar geometry helpers for the OSM → asset pipeline.
|
||||
|
||||
Pure Python: no `bpy`, so this runs and tests outside Blender. All functions
|
||||
work in projected metres (see `osmassets.osm.Projector`) unless the name says
|
||||
otherwise; `geometry_rings` and `feature_in_bounds` take raw GeoJSON and are the
|
||||
two exceptions, operating on lon/lat.
|
||||
|
||||
Rings are lists of (x, y) tuples. A repeated closing point is tolerated
|
||||
everywhere but never required.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def geometry_rings(geometry):
|
||||
"""Exterior rings of a GeoJSON Polygon/MultiPolygon; holes are dropped."""
|
||||
if not geometry:
|
||||
return []
|
||||
kind = geometry.get("type")
|
||||
coordinates = geometry.get("coordinates", [])
|
||||
if kind == "Polygon":
|
||||
return coordinates[:1]
|
||||
if kind == "MultiPolygon":
|
||||
return [polygon[0] for polygon in coordinates if polygon]
|
||||
return []
|
||||
|
||||
|
||||
def feature_in_bounds(feature, projector):
|
||||
"""True when any coordinate of the feature falls inside the padded bounds."""
|
||||
def walk(value):
|
||||
if isinstance(value, list) and value and isinstance(value[0], (int, float)):
|
||||
return projector.inside(value)
|
||||
return any(walk(v) for v in value) if isinstance(value, list) else False
|
||||
return walk(feature.get("geometry", {}).get("coordinates", []))
|
||||
|
||||
|
||||
def clip_polygon(ring, xmin, xmax, ymin, ymax):
|
||||
"""Sutherland-Hodgman clip of a ring against an axis-aligned box."""
|
||||
if len(ring) < 3:
|
||||
return []
|
||||
|
||||
def clip_edge(points, inside, intersection):
|
||||
if not points:
|
||||
return []
|
||||
result = []
|
||||
previous = points[-1]
|
||||
previous_inside = inside(previous)
|
||||
for current in points:
|
||||
current_inside = inside(current)
|
||||
if current_inside != previous_inside:
|
||||
result.append(intersection(previous, current))
|
||||
if current_inside:
|
||||
result.append(current)
|
||||
previous = current
|
||||
previous_inside = current_inside
|
||||
return result
|
||||
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[0] >= xmin,
|
||||
lambda a, b: (xmin, a[1] + (b[1] - a[1]) * (xmin - a[0]) /
|
||||
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[0] <= xmax,
|
||||
lambda a, b: (xmax, a[1] + (b[1] - a[1]) * (xmax - a[0]) /
|
||||
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[1] >= ymin,
|
||||
lambda a, b: (a[0] + (b[0] - a[0]) * (ymin - a[1]) /
|
||||
(b[1] - a[1]) if b[1] != a[1] else a[0], ymin))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[1] <= ymax,
|
||||
lambda a, b: (a[0] + (b[0] - a[0]) * (ymax - a[1]) /
|
||||
(b[1] - a[1]) if b[1] != a[1] else a[0], ymax))
|
||||
return ring
|
||||
|
||||
|
||||
def sample_tree_row(points, spacing, height):
|
||||
"""Evenly space (x, y, height) samples along a polyline.
|
||||
|
||||
The trailing point is appended only when the last regular sample stops well
|
||||
short of it, so a row does not end in a double-planted tree.
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return []
|
||||
samples = [(points[0][0], points[0][1], height)]
|
||||
distance_until_next = spacing
|
||||
for start, end in zip(points, points[1:]):
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
segment_length = math.hypot(dx, dy)
|
||||
if segment_length == 0:
|
||||
continue
|
||||
while distance_until_next <= segment_length:
|
||||
ratio = distance_until_next / segment_length
|
||||
samples.append((start[0] + dx * ratio, start[1] + dy * ratio, height))
|
||||
distance_until_next += spacing
|
||||
distance_until_next -= segment_length
|
||||
last = points[-1]
|
||||
if math.hypot(samples[-1][0] - last[0], samples[-1][1] - last[1]) > spacing * 0.45:
|
||||
samples.append((last[0], last[1], height))
|
||||
return samples
|
||||
|
||||
|
||||
def polygon_area(ring):
|
||||
"""Unsigned shoelace area; 0.0 for degenerate rings."""
|
||||
if len(ring) < 3:
|
||||
return 0.0
|
||||
area = 0.0
|
||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||
area += x1 * y2 - x2 * y1
|
||||
return abs(area) * 0.5
|
||||
|
||||
|
||||
def point_in_polygon(point, ring):
|
||||
x, y = point
|
||||
inside = False
|
||||
j = len(ring) - 1
|
||||
for i, (xi, yi) in enumerate(ring):
|
||||
xj, yj = ring[j]
|
||||
crosses = ((yi > y) != (yj > y))
|
||||
if crosses:
|
||||
x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi
|
||||
if x < x_at_y:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def distance_to_ring(point, ring):
|
||||
"""Shortest distance from a point to the ring's edges (not its interior)."""
|
||||
px, py = point
|
||||
best = float("inf")
|
||||
count = len(ring)
|
||||
for index in range(count):
|
||||
ax, ay = ring[index]
|
||||
bx, by = ring[(index + 1) % count]
|
||||
dx = bx - ax
|
||||
dy = by - ay
|
||||
length_sq = dx * dx + dy * dy
|
||||
if length_sq <= 1e-9:
|
||||
distance = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
||||
t = max(0.0, min(1.0, t))
|
||||
distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
if distance < best:
|
||||
best = distance
|
||||
return best
|
||||
22
blender/osmassets/grass.py
Normal file
22
blender/osmassets/grass.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Grass feature assembly (`landuse=grass`) with optional tuft scattering."""
|
||||
|
||||
from osmassets.geom import clip_polygon
|
||||
from osmassets.mesh import MeshBatch
|
||||
|
||||
|
||||
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||
green_c, grass_mat, tuft_variants, add_grass_tufts_fn):
|
||||
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||
name = "Grass_" + str(way_id)
|
||||
focus = list(ring)
|
||||
if len(ring) < 3:
|
||||
return 0, 0, focus
|
||||
batch = MeshBatch(name, green_c, grass_mat)
|
||||
batch.add_polygon(ring, 0.015)
|
||||
obj = batch.finish()
|
||||
tufts = 0
|
||||
if tuft_variants:
|
||||
tufts = add_grass_tufts_fn(name, ring, tuft_variants, green_c)
|
||||
if obj:
|
||||
obj["grass_tufts"] = tufts
|
||||
return 1, tufts, focus
|
||||
172
blender/osmassets/materials.py
Normal file
172
blender/osmassets/materials.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Blender material construction.
|
||||
|
||||
Requires `bpy`; only runs inside Blender. The catalog (`osmassets.catalog`)
|
||||
declares *what* a material is, this module builds it — that split is what keeps
|
||||
the catalog importable by plain Python, and by anything else that wants to read
|
||||
the scene's material definitions without launching Blender.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
TEXTURE_ROOT = os.path.abspath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..",
|
||||
"assets", "textures", "polyhaven"
|
||||
))
|
||||
|
||||
|
||||
def principled_bsdf(material):
|
||||
if not material.use_nodes:
|
||||
return None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def make_material(name, color, roughness=0.8, metallic=0.0):
|
||||
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
||||
material.diffuse_color = (*color, 1.0)
|
||||
material.use_nodes = True
|
||||
bsdf = principled_bsdf(material)
|
||||
if bsdf:
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = roughness
|
||||
bsdf.inputs["Metallic"].default_value = metallic
|
||||
return material
|
||||
|
||||
|
||||
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08,
|
||||
object_space=False):
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
bsdf = principled_bsdf(material)
|
||||
if not bsdf:
|
||||
return
|
||||
noise = nodes.new("ShaderNodeTexNoise")
|
||||
noise.inputs["Scale"].default_value = scale
|
||||
noise.inputs["Detail"].default_value = detail
|
||||
noise.inputs["Roughness"].default_value = 0.65
|
||||
texcoord = nodes.new("ShaderNodeTexCoord")
|
||||
ramp = nodes.new("ShaderNodeValToRGB")
|
||||
ramp.color_ramp.elements[0].color = (*colors[0], 1.0)
|
||||
ramp.color_ramp.elements[1].color = (*colors[1], 1.0)
|
||||
bump = nodes.new("ShaderNodeBump")
|
||||
bump.inputs["Strength"].default_value = bump_strength
|
||||
bump.inputs["Distance"].default_value = 0.12
|
||||
# "Generated" normalises across the object bounding box, so on a mesh that
|
||||
# spans the whole scene the noise stretches to tens of metres and vanishes.
|
||||
# Object space keeps the scale in metres, which is what foliage needs.
|
||||
source = "Object" if object_space else "Generated"
|
||||
links.new(texcoord.outputs[source], noise.inputs["Vector"])
|
||||
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
||||
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
links.new(noise.outputs["Fac"], bump.inputs["Height"])
|
||||
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
|
||||
|
||||
def tint_base_color(material, tint, factor):
|
||||
"""Mix an existing material's base colour toward `tint`.
|
||||
|
||||
Imported assets arrive with their own diffuse texture wired up. Rather than
|
||||
replacing it — which throws away the leaf detail — this splices a mix node
|
||||
in front of the Base Color input so the texture survives at (1 - factor).
|
||||
"""
|
||||
if factor <= 0.0 or not material.use_nodes:
|
||||
return
|
||||
bsdf = principled_bsdf(material)
|
||||
if not bsdf:
|
||||
return
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
base = bsdf.inputs["Base Color"]
|
||||
tint_node = nodes.new("ShaderNodeRGB")
|
||||
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
||||
mix = nodes.new("ShaderNodeMixRGB")
|
||||
mix.blend_type = "MIX"
|
||||
mix.inputs["Fac"].default_value = factor
|
||||
if base.is_linked:
|
||||
# Capture the upstream socket before relinking; Blender drops the old
|
||||
# link as soon as the input takes a new one.
|
||||
links.new(base.links[0].from_socket, mix.inputs[1])
|
||||
else:
|
||||
mix.inputs[1].default_value = base.default_value
|
||||
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
||||
links.new(mix.outputs["Color"], base)
|
||||
|
||||
|
||||
def make_textured_material(name, diffuse_file, normal_file, roughness,
|
||||
scale, normal_is_bump=False, metallic=0.0,
|
||||
tint=None, tint_factor=0.0):
|
||||
diffuse_path = os.path.join(TEXTURE_ROOT, diffuse_file)
|
||||
normal_path = os.path.join(TEXTURE_ROOT, normal_file)
|
||||
if not os.path.exists(diffuse_path) or not os.path.exists(normal_path):
|
||||
return make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
||||
|
||||
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
bsdf = principled_bsdf(material)
|
||||
if not bsdf:
|
||||
return material
|
||||
texcoord = nodes.new("ShaderNodeTexCoord")
|
||||
mapping = nodes.new("ShaderNodeMapping")
|
||||
mapping.inputs["Scale"].default_value = (scale, scale, scale)
|
||||
diffuse = nodes.new("ShaderNodeTexImage")
|
||||
diffuse.image = bpy.data.images.load(diffuse_path, check_existing=True)
|
||||
diffuse.extension = "REPEAT"
|
||||
normal = nodes.new("ShaderNodeTexImage")
|
||||
normal.image = bpy.data.images.load(normal_path, check_existing=True)
|
||||
normal.image.colorspace_settings.name = "Non-Color"
|
||||
normal.extension = "REPEAT"
|
||||
links.new(texcoord.outputs["Generated"], mapping.inputs["Vector"])
|
||||
links.new(mapping.outputs["Vector"], diffuse.inputs["Vector"])
|
||||
links.new(mapping.outputs["Vector"], normal.inputs["Vector"])
|
||||
if tint and tint_factor > 0.0:
|
||||
tint_node = nodes.new("ShaderNodeRGB")
|
||||
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
||||
mix = nodes.new("ShaderNodeMixRGB")
|
||||
mix.blend_type = "MIX"
|
||||
mix.inputs["Fac"].default_value = tint_factor
|
||||
links.new(diffuse.outputs["Color"], mix.inputs[1])
|
||||
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
||||
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
else:
|
||||
links.new(diffuse.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
if normal_is_bump:
|
||||
bump = nodes.new("ShaderNodeBump")
|
||||
bump.inputs["Strength"].default_value = 0.22
|
||||
bump.inputs["Distance"].default_value = 0.12
|
||||
links.new(normal.outputs["Color"], bump.inputs["Height"])
|
||||
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
else:
|
||||
normal_map = nodes.new("ShaderNodeNormalMap")
|
||||
normal_map.inputs["Strength"].default_value = 0.52
|
||||
links.new(normal.outputs["Color"], normal_map.inputs["Color"])
|
||||
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
return material
|
||||
|
||||
|
||||
def from_spec(spec):
|
||||
"""Build a material from a `catalog.MATERIALS` entry."""
|
||||
if spec["kind"] == "textured":
|
||||
return make_textured_material(
|
||||
spec["name"], spec["diffuse"], spec["normal"],
|
||||
roughness=spec.get("roughness", 0.8), scale=spec["scale"],
|
||||
normal_is_bump=spec.get("normal_is_bump", False),
|
||||
metallic=spec.get("metallic", 0.0),
|
||||
tint=spec.get("tint"), tint_factor=spec.get("tint_factor", 0.0))
|
||||
|
||||
material = make_material(spec["name"], spec["color"],
|
||||
spec.get("roughness", 0.8),
|
||||
spec.get("metallic", 0.0))
|
||||
procedural = spec.get("procedural")
|
||||
if procedural:
|
||||
add_procedural_surface(material, procedural["colors"],
|
||||
scale=procedural["scale"],
|
||||
detail=procedural["detail"],
|
||||
bump_strength=procedural["bump_strength"],
|
||||
object_space=procedural.get("object_space", False))
|
||||
return material
|
||||
126
blender/osmassets/mesh.py
Normal file
126
blender/osmassets/mesh.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Mesh and collection construction for the Blender scene.
|
||||
|
||||
Requires `bpy`; only runs inside Blender.
|
||||
|
||||
`MeshBatch` is the workhorse: most of the scene is flat polygons and extruded
|
||||
prisms, and batching them into one mesh datablock per logical group keeps the
|
||||
object count (and the glTF node count) down. Callers accumulate geometry and
|
||||
call `finish()` once.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def new_collection(name):
|
||||
collection = bpy.data.collections.new(name)
|
||||
bpy.context.scene.collection.children.link(collection)
|
||||
return collection
|
||||
|
||||
|
||||
def link_object_to_collection(obj, collection):
|
||||
for current in list(obj.users_collection):
|
||||
current.objects.unlink(obj)
|
||||
collection.objects.link(obj)
|
||||
|
||||
|
||||
class MeshBatch:
|
||||
"""Accumulates polygons/prisms into a single mesh object."""
|
||||
|
||||
def __init__(self, name, collection, material):
|
||||
self.name = name
|
||||
self.collection = collection
|
||||
self.material = material
|
||||
self.vertices = []
|
||||
self.faces = []
|
||||
|
||||
def add_polygon(self, ring, z):
|
||||
if len(ring) < 3:
|
||||
return
|
||||
if ring[0] == ring[-1]:
|
||||
ring = ring[:-1]
|
||||
if len(ring) < 3:
|
||||
return
|
||||
start = len(self.vertices)
|
||||
self.vertices.extend((x, y, z) for x, y in ring)
|
||||
self.faces.append(tuple(range(start, start + len(ring))))
|
||||
|
||||
def add_prism(self, ring, base, height):
|
||||
if len(ring) < 3:
|
||||
return
|
||||
if ring[0] == ring[-1]:
|
||||
ring = ring[:-1]
|
||||
if len(ring) < 3:
|
||||
return
|
||||
start = len(self.vertices)
|
||||
self.vertices.extend((x, y, base) for x, y in ring)
|
||||
self.vertices.extend((x, y, base + height) for x, y in ring)
|
||||
n = len(ring)
|
||||
self.faces.append(tuple(range(start, start + n)))
|
||||
self.faces.append(tuple(range(start + n, start + 2 * n)))
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
self.faces.append((start + i, start + j, start + n + j, start + n + i))
|
||||
|
||||
def finish(self):
|
||||
if not self.vertices:
|
||||
return None
|
||||
mesh = bpy.data.meshes.new(self.name + "Mesh")
|
||||
mesh.from_pydata(self.vertices, [], self.faces)
|
||||
mesh.materials.append(self.material)
|
||||
# Foliage reads as blobby volume, so it wants smooth normals; the built
|
||||
# environment wants its facets. The name prefix is the discriminator.
|
||||
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
|
||||
for polygon in mesh.polygons:
|
||||
polygon.use_smooth = True
|
||||
mesh.update()
|
||||
obj = bpy.data.objects.new(self.name, mesh)
|
||||
self.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def make_prism(name, ring, base, height, material, collection):
|
||||
batch = MeshBatch(name, collection, material)
|
||||
batch.add_prism(ring, base, height)
|
||||
return batch.finish()
|
||||
|
||||
|
||||
def add_roof(name, ring, z, material, collection):
|
||||
batch = MeshBatch(name + "_Roof", collection, material)
|
||||
batch.add_polygon(ring, z)
|
||||
return batch.finish()
|
||||
|
||||
|
||||
def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08):
|
||||
"""Add a thin inset slab along a facade edge, used for window bands."""
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 3.0:
|
||||
return
|
||||
ux, uy = dx / length, dy / length
|
||||
a = (start[0] + dx * inset, start[1] + dy * inset)
|
||||
b = (end[0] - dx * inset, end[1] - dy * inset)
|
||||
nx, ny = -uy * thickness / 2, ux * thickness / 2
|
||||
panel = [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
|
||||
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
|
||||
batch.add_prism(panel, base, height)
|
||||
|
||||
|
||||
def add_polyline(name, coords, projector, collection, material, width, z):
|
||||
"""Bevelled curve along lon/lat coordinates; the OSM highway road fallback."""
|
||||
points = [projector.xy(c) for c in coords]
|
||||
if len(points) < 2:
|
||||
return
|
||||
curve = bpy.data.curves.new(name, "CURVE")
|
||||
curve.dimensions = "3D"
|
||||
curve.resolution_u = 1
|
||||
curve.bevel_depth = width / 2
|
||||
curve.bevel_resolution = 1
|
||||
spline = curve.splines.new("POLY")
|
||||
spline.points.add(len(points) - 1)
|
||||
for point, (x, y) in zip(spline.points, points):
|
||||
point.co = (x, y, z, 1)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
collection.objects.link(obj)
|
||||
obj.data.materials.append(material)
|
||||
89
blender/osmassets/osm.py
Normal file
89
blender/osmassets/osm.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""OSM XML parsing and the local metric projection.
|
||||
|
||||
Pure Python: no `bpy`, so this runs and tests outside Blender.
|
||||
"""
|
||||
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def tags(element):
|
||||
return {t.attrib.get("k", ""): t.attrib.get("v", "")
|
||||
for t in element.findall("tag")}
|
||||
|
||||
|
||||
def parse_osm(path):
|
||||
root = ET.parse(path).getroot()
|
||||
bounds_node = root.find("bounds")
|
||||
if bounds_node is None:
|
||||
raise RuntimeError("OSM file does not contain a bounds element")
|
||||
bounds = {"min_lon": float(bounds_node.attrib["minlon"]),
|
||||
"min_lat": float(bounds_node.attrib["minlat"]),
|
||||
"max_lon": float(bounds_node.attrib["maxlon"]),
|
||||
"max_lat": float(bounds_node.attrib["maxlat"])}
|
||||
|
||||
nodes = {}
|
||||
point_features = []
|
||||
for node in root.findall("node"):
|
||||
try:
|
||||
node_id = int(node.attrib["id"])
|
||||
coord = (float(node.attrib["lon"]), float(node.attrib["lat"]))
|
||||
node_tags = tags(node)
|
||||
nodes[node_id] = coord
|
||||
if node_tags:
|
||||
point_features.append({"id": node.attrib.get("id", ""),
|
||||
"coord": coord, "tags": node_tags})
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
|
||||
ways = []
|
||||
for way in root.findall("way"):
|
||||
if way.attrib.get("action") == "delete":
|
||||
continue
|
||||
refs = []
|
||||
for ref in way.findall("nd"):
|
||||
try:
|
||||
refs.append(int(ref.attrib["ref"]))
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
coords = [nodes[r] for r in refs if r in nodes]
|
||||
if len(coords) >= 2:
|
||||
ways.append({"id": way.attrib.get("id", ""),
|
||||
"coords": coords, "tags": tags(way)})
|
||||
return bounds, ways, point_features
|
||||
|
||||
|
||||
def parse_height(feature_tags, default):
|
||||
try:
|
||||
return max(0.5, float(feature_tags.get("height", default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
class Projector:
|
||||
"""Equirectangular projection about the centre of the OSM bounds.
|
||||
|
||||
Output is metres in a local ENU frame (X east, Y north), which is what both
|
||||
the Blender scene and the Cesium GLB are authored in.
|
||||
"""
|
||||
|
||||
def __init__(self, bounds):
|
||||
self.bounds = bounds
|
||||
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
|
||||
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
|
||||
self.m_per_lat = 111320.0
|
||||
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
|
||||
|
||||
def xy(self, lon_lat):
|
||||
lon, lat = lon_lat
|
||||
return ((lon - self.lon0) * self.m_per_lon,
|
||||
(lat - self.lat0) * self.m_per_lat)
|
||||
|
||||
def inside(self, lon_lat, pad=0.00035):
|
||||
lon, lat = lon_lat
|
||||
b = self.bounds
|
||||
return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and
|
||||
b["min_lat"] - pad <= lat <= b["max_lat"] + pad)
|
||||
|
||||
def ring(self, coords):
|
||||
return [self.xy(c) for c in coords]
|
||||
13
blender/osmassets/scrub.py
Normal file
13
blender/osmassets/scrub.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Scrub feature assembly (`natural=scrub`). Flat ground cover only."""
|
||||
|
||||
from osmassets.geom import clip_polygon
|
||||
|
||||
|
||||
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||
green_c, scrub_mat, add_scrub_patch_fn):
|
||||
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||
focus = list(ring)
|
||||
if len(ring) < 3:
|
||||
return 0, focus
|
||||
add_scrub_patch_fn("Scrub_" + str(way_id), ring, scrub_mat, green_c)
|
||||
return 1, focus
|
||||
15
blender/osmassets/water.py
Normal file
15
blender/osmassets/water.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Water feature assembly (`natural=water` or `water=lake`)."""
|
||||
|
||||
from osmassets.geom import clip_polygon
|
||||
from osmassets.mesh import MeshBatch
|
||||
|
||||
|
||||
def assemble(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||
water_c, water_mat):
|
||||
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||
if len(ring) < 3:
|
||||
return 0
|
||||
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
||||
batch.add_polygon(ring, 0.10)
|
||||
batch.finish()
|
||||
return 1
|
||||
Reference in New Issue
Block a user