"""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())