Files
osmWorkflow/blender/export_cesium.py
2026-07-24 16:03:15 +08:00

404 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Export the Nantaizi Blender master scene as a Cesium-ready GLB.
The authoring scene intentionally uses a few Blender-only nodes (for example
the grass tint and procedural tree crown variation). glTF has a smaller
material vocabulary, so this exporter creates temporary, export-only PBR
materials, unwraps the meshes, and embeds all referenced images in the GLB.
The model remains in a local ENU frame: X east, Y north, Z up. Use the
companion JSON file to place the GLB with Cesium.Transforms.eastNorthUpToFixedFrame.
"""
import json
import os
import sys
import bpy
import numpy as np
DEFAULT_BLEND = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.blend"
)
DEFAULT_GLB = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley_cesium.glb"
)
DEFAULT_METADATA = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley_cesium.json"
)
# Blender 里使用 MixRGB 在 Poly Haven 贴图上叠加颜色修正。
# glTF/Cesium 不能稳定保留这类 Blender 专用节点,所以导出前要把
# 同样的 tint 烘焙到临时图片里,再写入 GLB。
# 这样 Cesium 预览会尽量接近 Blender 视图,也能避免浅色办公楼外墙、
# 屋顶在导出后退回偏黑的原始贴图。
EXPORT_TINTS = {
"Grass": ((0.12, 0.48, 0.08), 0.72),
"Office White Plaster Facade": ((0.92, 0.94, 0.92), 0.38),
# 兼容旧 .blend普通办公楼从金属板切回灰泥前生成的文件
# 可能还保留这个旧材质名。
"Office White Metal Facade": ((0.92, 0.94, 0.92), 0.68),
"Office Light Flat Roof": ((0.82, 0.86, 0.88), 0.35),
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.86),
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.58),
}
# Cesium 的光照比 Blender 材质预览更“硬”。外墙默认不要保留金属度,
# 除非确实是工业金属墙面;否则旧金属墙贴图直接导出时容易发黑。
EXPORT_METALLIC_OVERRIDES = {
"Office White Plaster Facade": 0.0,
"Office White Metal Facade": 0.0,
"Industrial White Ribbed Facade": 0.08,
}
# 普通办公楼在 Cesium 里使用稳定浅色展示材质。
# 这些建筑不是工业厂房,导出时如果继续采样原始墙面/屋顶贴图,
# Cesium 的光照和 mipmap 会把贴图里的暗斑放大,画面就会显得脏黑。
# 所以这里直接覆盖 Base Color只保留几何、窗带和必要法线细节。
EXPORT_BASE_COLOR_OVERRIDES = {
"Office White Plaster Facade": (0.93, 0.94, 0.91),
"Office White Metal Facade": (0.93, 0.94, 0.91),
"Office Light Flat Roof": (0.88, 0.90, 0.88),
}
# Cesium 是真实 PBR 光照,侧墙和屋顶会比 Blender 材质预览暗很多。
# 给普通建筑加很弱的 emissive 补光,不是做“发光楼”,只是模拟
# Blender 预览里的环境光,让浅色办公楼在网页里保持干净明亮。
EXPORT_EMISSION_OVERRIDES = {
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
"Office White Metal Facade": ((0.93, 0.94, 0.91), 0.18),
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
# 三栋厂房仍保留金属/波纹质感,但 Cesium 里原贴图会偏暗。
# 这里加少量补光,让墙面和蓝色屋顶更接近 Blender 预览。
"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 cli_args():
values = {"blend": DEFAULT_BLEND, "glb": DEFAULT_GLB,
"metadata": DEFAULT_METADATA}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
if argv[i].startswith("--") and i + 1 < len(argv):
values[argv[i][2:]] = argv[i + 1]
i += 2
else:
i += 1
return values
def image_for(material, want_normal=False):
"""Find a diffuse/normal image from the authoring material nodes."""
candidates = []
for node in material.node_tree.nodes:
if node.type != "TEX_IMAGE" or not node.image:
continue
name = os.path.basename(node.image.name).lower()
is_normal = "_nor_" in name or "_normal" in name or "_bump" in name
if is_normal == want_normal:
candidates.append(node.image)
return candidates[0] if candidates else None
def source_color(material):
color = tuple(material.diffuse_color[:3])
if len(color) != 3:
return (0.5, 0.5, 0.5)
return color
def source_principled_value(material, input_name, fallback):
if not material.use_nodes:
return fallback
node = material.node_tree.nodes.get("Principled BSDF")
if not node or input_name not in node.inputs:
return fallback
return node.inputs[input_name].default_value
def source_texture_scale(material):
for node in material.node_tree.nodes:
if node.type == "MAPPING":
return tuple(node.inputs["Scale"].default_value[:3])
return (1.0, 1.0, 1.0)
def tinted_image(source, name, tint, factor):
"""Bake Blender's MixRGB tint into a generated image for glTF."""
existing = bpy.data.images.get(name)
if existing:
return existing
width, height = source.size
pixels = np.empty(width * height * 4, dtype=np.float32)
source.pixels.foreach_get(pixels)
rgba = pixels.reshape((-1, 4))
rgba[:, :3] = rgba[:, :3] * (1.0 - factor) + np.asarray(
tint, dtype=np.float32) * factor
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
result.file_format = "PNG"
result.colorspace_settings.name = "sRGB"
result.pixels.foreach_set(pixels)
result.pack()
return result
def cesium_tinted_image(material, source):
"""Return a baked diffuse image matching the Blender authoring tint."""
tint = EXPORT_TINTS.get(material.name)
if not tint or not source:
return source
color, factor = tint
safe_name = material.name.replace(" ", "_")
return tinted_image(source, f"Cesium {safe_name} Baked", color, factor)
def tree_crown_image():
"""Bake a compact leafy color variation texture for the web material."""
name = "Cesium Tree Crown Baked"
existing = bpy.data.images.get(name)
if existing:
return existing
size = 256
x, y = np.meshgrid(
np.linspace(0.0, 1.0, size, dtype=np.float32),
np.linspace(0.0, 1.0, size, dtype=np.float32),
)
noise = (
np.sin((x * 17.0 + y * 7.0) * np.pi) * 0.24 +
np.sin((x * 43.0 - y * 31.0) * np.pi) * 0.13 +
np.sin((x * 89.0 + y * 67.0) * np.pi) * 0.07
)
noise = np.clip(0.5 + noise, 0.0, 1.0)[..., None]
dark = np.asarray((0.035, 0.16, 0.045), dtype=np.float32)
light = np.asarray((0.12, 0.42, 0.13), dtype=np.float32)
rgb = dark + (light - dark) * noise
rgba = np.concatenate(
(rgb, np.ones((size, size, 1), dtype=np.float32)), axis=2)
result = bpy.data.images.new(name, width=size, height=size, alpha=True)
result.file_format = "PNG"
result.colorspace_settings.name = "sRGB"
result.pixels.foreach_set(rgba.ravel())
result.pack()
return result
def make_export_material(material):
"""Build a small Principled + Image Texture material understood by glTF."""
result = material.copy()
result.name = "Cesium " + material.name
result.use_nodes = True
nodes = result.node_tree.nodes
links = result.node_tree.links
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
output.location = (520, 0)
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.location = (250, 0)
base_color = EXPORT_BASE_COLOR_OVERRIDES.get(
material.name, source_color(material))
bsdf.inputs["Base Color"].default_value = (*base_color, 1.0)
bsdf.inputs["Roughness"].default_value = source_principled_value(
material, "Roughness", 0.8)
bsdf.inputs["Metallic"].default_value = EXPORT_METALLIC_OVERRIDES.get(
material.name, source_principled_value(material, "Metallic", 0.0))
emission = EXPORT_EMISSION_OVERRIDES.get(material.name)
if emission:
emission_color, emission_strength = emission
if "Emission Color" in bsdf.inputs:
bsdf.inputs["Emission Color"].default_value = (*emission_color, 1.0)
elif "Emission" in bsdf.inputs:
bsdf.inputs["Emission"].default_value = (*emission_color, 1.0)
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = emission_strength
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
diffuse = image_for(material, want_normal=False)
normal = image_for(material, want_normal=True)
if material.name == "Tree Crown":
diffuse = tree_crown_image()
else:
diffuse = cesium_tinted_image(material, diffuse)
if material.name in EXPORT_BASE_COLOR_OVERRIDES:
# 普通办公楼/浅色屋顶使用上面的稳定浅色 Base Color。
# 不连接漫反射贴图和法线贴图,避免 Cesium 里重新出现偏黑、
# 偏脏的斑驳效果,也避免法线贴图在硬光照下把屋顶压暗。
diffuse = None
normal = None
mapping = None
if diffuse or normal:
texcoord = nodes.new("ShaderNodeTexCoord")
texcoord.location = (-650, 0)
mapping = nodes.new("ShaderNodeMapping")
mapping.location = (-450, 0)
# The authoring materials use Generated coordinates with these scales.
# UVs are used here because Generated coordinates are not part of glTF.
mapping.inputs["Scale"].default_value = source_texture_scale(material)
links.new(texcoord.outputs["UV"], mapping.inputs["Vector"])
if diffuse:
image = nodes.new("ShaderNodeTexImage")
image.location = (-200, 80)
image.image = diffuse
image.extension = "REPEAT"
links.new(mapping.outputs["Vector"], image.inputs["Vector"])
links.new(image.outputs["Color"], bsdf.inputs["Base Color"])
if normal:
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.location = (-200, -180)
normal_tex.image = normal
normal_tex.image.colorspace_settings.name = "Non-Color"
normal_tex.extension = "REPEAT"
normal_map = nodes.new("ShaderNodeNormalMap")
normal_map.location = (20, -160)
normal_map.inputs["Strength"].default_value = 0.52
links.new(mapping.outputs["Vector"], normal_tex.inputs["Vector"])
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
# These values are deliberately conservative for web rendering. In
# particular, avoid transmission/alpha because the source scene has no
# transparent geometry and those features are expensive in Cesium.
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
return result
def unwrap_mesh(obj):
if obj.type != "MESH" or not obj.data.polygons:
return
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
try:
bpy.ops.uv.smart_project(island_margin=0.025, area_weight=0.0)
finally:
bpy.ops.object.mode_set(mode="OBJECT")
def apply_mesh_modifiers(obj):
if obj.type != "MESH":
return
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
for modifier in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError:
# A failed nonessential modifier should not prevent the rest of
# the park from exporting.
pass
def export(args):
if not os.path.exists(args["blend"]):
raise FileNotFoundError(args["blend"])
bpy.ops.wm.open_mainfile(filepath=args["blend"])
# Build one export material per source material and assign it in memory.
material_map = {}
meshes = []
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
continue
# Cesium supplies the ellipsoid/globe surface. The authoring ground
# plane is deliberately omitted so it cannot appear as a large flat
# rectangle over the basemap.
if obj.name == "Ground Plane":
continue
meshes.append(obj)
apply_mesh_modifiers(obj)
unwrap_mesh(obj)
for slot in obj.material_slots:
if not slot.material:
continue
source = slot.material
if source.name not in material_map:
material_map[source.name] = make_export_material(source)
slot.material = material_map[source.name]
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0] if meshes else None
os.makedirs(os.path.dirname(args["glb"]), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=args["glb"],
export_format="GLB",
use_selection=True,
export_apply=False,
export_texcoords=True,
export_normals=True,
export_materials="EXPORT",
export_image_format="AUTO",
export_extras=True,
export_cameras=False,
export_lights=False,
)
scene = bpy.context.scene
try:
bounds = json.loads(scene.get("osm_bounds", "{}"))
except (TypeError, ValueError):
bounds = {}
if bounds:
center_lon = (bounds["min_lon"] + bounds["max_lon"]) / 2.0
center_lat = (bounds["min_lat"] + bounds["max_lat"]) / 2.0
else:
center_lon = center_lat = 0.0
metadata = {
"asset": os.path.basename(args["glb"]),
"coordinate_system": "local ENU meters (X east, Y north, Z up)",
"heading_correction_degrees": -90.0,
# A small offset prevents centimeter-high road markings and grass from
# fighting with the globe depth buffer at overview distances.
"anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35},
"bounds": bounds,
"source_osm": scene.get("source_osm", ""),
"source_geojson": scene.get("source_geojson", ""),
"scene_stats": {
"buildings": scene.get("building_count", 0),
"industrial_buildings": scene.get("industrial_building_count", 0),
"lake_polygons": scene.get("lake_count", 0),
"trees": scene.get("tree_count", 0),
"grass_polygons": scene.get("grass_count", 0),
"scrub_polygons": scene.get("scrub_count", 0),
"fountains": scene.get("fountain_count", 0),
},
"cesium_js": (
"const p = Cesium.Cartesian3.fromDegrees(" +
f"{center_lon:.8f}, {center_lat:.8f}, 0.35);\n" +
"const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);\n" +
"const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(-90.0));\n" +
"const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());\n" +
"Cesium.Model.fromGltfAsync({ url: '" + os.path.basename(args["glb"]) +
"', modelMatrix }).then(model => viewer.scene.primitives.add(model));"
),
}
os.makedirs(os.path.dirname(args["metadata"]), exist_ok=True)
with open(args["metadata"], "w", encoding="utf-8") as handle:
json.dump(metadata, handle, ensure_ascii=False, indent=2)
handle.write("\n")
# Keep the authoring blend untouched. The temporary export materials are
# only present in this Blender process and are not saved.
print("CESIUM_EXPORT_DONE", json.dumps({
"glb": args["glb"], "metadata": args["metadata"],
"meshes": len(meshes), "materials": len(material_map),
"anchor": [center_lon, center_lat],
}, ensure_ascii=True))
if __name__ == "__main__":
export(cli_args())