成果暂存

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

89
blender/README.md Normal file
View File

@@ -0,0 +1,89 @@
# Nantaizi Lake Blender scene
This generator combines the source OSM building, water and height tags with
the detailed osm2streets GeoJSON road layers.
## Outputs
- `../outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.blend`
- `../outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.png`
- `../outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley_cesium.glb`
- `../outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley_cesium.json`
- `../outputs/nantaizi-lake-innovation-valley/nantaizi_cesium_preview.html`
## Regenerate
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--factory-startup \
--python blender/generate_nantaizi.py -- \
--osm "/Users/que01/Desktop/南台子湖创新谷OSM.osm" \
--geojson "/Users/que01/osm2streets-qgis-workflow/outputs/nantaizi-lake-innovation-valley/osm2streets_web_out" \
--output "/Users/que01/osm2streets-qgis-workflow/outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.blend" \
--render "/Users/que01/osm2streets-qgis-workflow/outputs/nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.png"
```
The OSM file contains distant subway relation members. The generator uses the
explicit OSM `bounds` element to keep the scene limited to the park.
Vegetation is read from the OSM export: `natural=tree` nodes become individual
trees, `natural=tree_row` ways become evenly spaced rows using their height,
`landuse=grass` becomes green ground, and `natural=scrub` becomes low shrub
volumes. `amenity=fountain` nodes become low-poly fountain basins and water
sprays. No trees or fountains are inferred from open ground.
The renderer adds procedural presentation detail without changing OSM massing:
rounded layered crowns and tapered trunks for trees, window bands and roof
equipment for office buildings, and clerestory windows, skylights and roof
equipment for `building=industrial` footprints.
Ordinary `building=yes` footprints below 30 meters are rendered as three-floor
park offices at 11.4 meters plus a small roof unit. Explicit high-rises and all
`building=industrial` heights remain sourced from OSM. White plaster, gray
corrugated iron, grass and bark use 1K CC0 textures downloaded from Poly Haven.
The images used by the scene are packed into the generated `.blend`; source and
license details are recorded in `assets/textures/polyhaven/SOURCES.md`.
OSM ways `117753521` and `117753535` are explicitly rendered as three-floor
white offices because the surveyed site use differs from their current
`building=industrial` tags.
## Export for Cesium
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--python blender/export_cesium.py
```
The GLB uses a local east-north-up meter frame centered on the OSM bounds. Its
companion JSON records the WGS84 anchor used by the preview page. The export
script applies mesh modifiers, creates UVs, converts Blender-only materials to
glTF-compatible PBR materials, and embeds the used texture images in the GLB.
For the Cesium preview, use this script rather than Blender's manual glTF
export: it preserves the local ENU placement metadata and bakes/overrides the
building display materials so the web preview stays close to Blender's clean
material-preview look.
You can export directly from Blender for a quick material sanity check, but the
manual GLB will not create/update `nantaizi_lake_innovation_valley_cesium.json`
and therefore will not be positioned automatically by
`nantaizi_cesium_preview.html`.
Serve the output directory over HTTP before opening the preview because web
browsers do not allow the page to fetch GLB/JSON files from `file://`:
```bash
cd outputs/nantaizi-lake-innovation-valley
python3 -m http.server 8765
```
Then open `http://localhost:8765/nantaizi_cesium_preview.html`.
## Blender MCP
The current machine has the `blosm` add-on but no Blender MCP add-on or Codex
MCP server configuration. An MCP workflow requires both sides and a Codex
restart after adding the server. The generator remains useful with MCP because
it creates a stable base scene that can then be edited interactively.

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

1192
blender/generate_nantaizi.py Normal file

File diff suppressed because it is too large Load Diff