成果暂存

This commit is contained in:
2026-07-24 16:03:15 +08:00
parent 662f8d908b
commit fb2a36ac7e
32 changed files with 198341 additions and 12 deletions

403
blender/export_cesium.py Normal file
View File

@@ -0,0 +1,403 @@
"""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())