成果暂存
16
assets/models/polyhaven/78-hazelnutbush/Hazelnut.mtl
Normal file
@@ -0,0 +1,16 @@
|
||||
newmtl None
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Kd 1.000000 1.000000 1.000000
|
||||
Ks 0.000000 0.000000 0.000000
|
||||
d 1.000000
|
||||
illum 1
|
||||
map_Kd HazelnutBark.png
|
||||
|
||||
newmtl None_Hazelnut.png
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Kd 1.000000 1.000000 1.000000
|
||||
Ks 0.000000 0.000000 0.000000
|
||||
d 1.000000
|
||||
illum 1
|
||||
map_Kd HazelnutLeaves.png
|
||||
map_d HazelnutLeaves.png
|
||||
196422
assets/models/polyhaven/78-hazelnutbush/Hazelnut.obj
Normal file
BIN
assets/models/polyhaven/78-hazelnutbush/HazelnutBark.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
assets/models/polyhaven/78-hazelnutbush/HazelnutLeaves.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
assets/models/polyhaven/78-hazelnutbush/HazelnutLeavesMask.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
assets/models/polyhaven/78-hazelnutbush/HazelnutLeavesMask.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
22
assets/textures/polyhaven/SOURCES.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Poly Haven CC0 textures
|
||||
|
||||
All files in this directory are from Poly Haven and are released under the
|
||||
Poly Haven CC0 license. The license page is archived as `polyhaven-license.html`.
|
||||
|
||||
License: https://polyhaven.com/license
|
||||
|
||||
Assets:
|
||||
|
||||
- `concrete_floor_02`: https://polyhaven.com/a/concrete_floor_02
|
||||
- `factory_wall`: https://polyhaven.com/a/factory_wall
|
||||
- `corrugated_iron`: https://polyhaven.com/a/corrugated_iron
|
||||
- `box_profile_metal_sheet`: https://polyhaven.com/a/box_profile_metal_sheet
|
||||
- `corrugated_iron_03`: https://polyhaven.com/a/corrugated_iron_03
|
||||
- `blue_metal_plate`: https://polyhaven.com/a/blue_metal_plate
|
||||
- `leafy_grass`: https://polyhaven.com/a/leafy_grass
|
||||
- `bark_brown_01`: https://polyhaven.com/a/bark_brown_01
|
||||
- `white_plaster_02`: https://polyhaven.com/a/white_plaster_02
|
||||
- `shrub_02`: https://polyhaven.com/a/shrub_02
|
||||
|
||||
Only the 1K diffuse and normal/bump maps needed by the Blender scene were
|
||||
downloaded. They may be used commercially under the CC0 terms above.
|
||||
BIN
assets/textures/polyhaven/bark_brown_01_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 713 KiB |
BIN
assets/textures/polyhaven/bark_brown_01_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/textures/polyhaven/blue_metal_plate_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
assets/textures/polyhaven/blue_metal_plate_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
assets/textures/polyhaven/box_profile_metal_sheet_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
assets/textures/polyhaven/box_profile_metal_sheet_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
assets/textures/polyhaven/concrete_floor_02_bump_1k.jpg
Normal file
|
After Width: | Height: | Size: 713 KiB |
BIN
assets/textures/polyhaven/concrete_floor_02_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 758 KiB |
BIN
assets/textures/polyhaven/corrugated_iron_03_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
assets/textures/polyhaven/corrugated_iron_03_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
assets/textures/polyhaven/corrugated_iron_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 703 KiB |
BIN
assets/textures/polyhaven/corrugated_iron_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 738 KiB |
BIN
assets/textures/polyhaven/factory_wall_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
assets/textures/polyhaven/factory_wall_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
assets/textures/polyhaven/leafy_grass_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/textures/polyhaven/leafy_grass_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
77
assets/textures/polyhaven/polyhaven-license.html
Normal file
BIN
assets/textures/polyhaven/shrub_02_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
assets/textures/polyhaven/shrub_02_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
assets/textures/polyhaven/white_plaster_02_diff_1k.jpg
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
assets/textures/polyhaven/white_plaster_02_nor_gl_1k.jpg
Normal file
|
After Width: | Height: | Size: 637 KiB |
89
blender/README.md
Normal 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
@@ -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
@@ -69,6 +69,11 @@ writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLine
|
||||
writeJson(path.join(outDir, "lane_arrows_webscale.geojson"), split.laneArrows);
|
||||
writeJson(path.join(outDir, "sidewalk_corners.geojson"), split.sidewalkCorners);
|
||||
writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks);
|
||||
writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split));
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "osm2streets_scene_style.json"),
|
||||
JSON.stringify(sceneStyle(), null, 2),
|
||||
);
|
||||
|
||||
if (fs.existsSync(gpkgPath)) {
|
||||
fs.unlinkSync(gpkgPath);
|
||||
@@ -206,6 +211,23 @@ function qgisEnv() {
|
||||
}
|
||||
|
||||
function getOsmBounds(xmlText) {
|
||||
// OSM exports may contain distant relation members (for example subway
|
||||
// nodes) that are not part of the requested map extent. Prefer the explicit
|
||||
// bounds element when present and only fall back to node extents for files
|
||||
// that do not provide one.
|
||||
const boundsMatch = xmlText.match(/<bounds\b([^>]*)\/?\s*>/);
|
||||
if (boundsMatch) {
|
||||
const attrs = parseAttrs(boundsMatch[1]);
|
||||
const values = {
|
||||
minLon: Number(attrs.minlon),
|
||||
minLat: Number(attrs.minlat),
|
||||
maxLon: Number(attrs.maxlon),
|
||||
maxLat: Number(attrs.maxlat),
|
||||
};
|
||||
if (Object.values(values).every(Number.isFinite)) {
|
||||
return values;
|
||||
}
|
||||
}
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
@@ -357,6 +379,53 @@ function emptyCollection() {
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
}
|
||||
|
||||
function mergedScene(split) {
|
||||
const layers = [
|
||||
["road_surface", 10, split.roadSurface],
|
||||
["intersection_surface", 20, split.intersectionSurface],
|
||||
["sidewalks", 30, split.sidewalks],
|
||||
["sidewalk_corners", 40, split.sidewalkCorners],
|
||||
["lane_separators", 50, split.laneSeparators],
|
||||
["center_lines", 60, split.centerLines],
|
||||
["crosswalks", 70, split.crosswalks],
|
||||
["vehicle_stop_lines", 80, split.vehicleStopLines],
|
||||
["lane_arrows_webscale", 90, split.laneArrows],
|
||||
];
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: layers.flatMap(([renderLayer, zIndex, collection]) => (
|
||||
(collection.features || []).map((feature) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...(feature.properties || {}),
|
||||
render_layer: renderLayer,
|
||||
z_index: zIndex,
|
||||
},
|
||||
}))
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function sceneStyle() {
|
||||
return {
|
||||
version: 1,
|
||||
geometry: "polygon",
|
||||
sortProperty: "z_index",
|
||||
layerProperty: "render_layer",
|
||||
layers: [
|
||||
{ id: "road_surface", zIndex: 10, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 },
|
||||
{ id: "intersection_surface", zIndex: 20, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 },
|
||||
{ id: "sidewalks", zIndex: 30, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 },
|
||||
{ id: "sidewalk_corners", zIndex: 40, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 },
|
||||
{ id: "lane_separators", zIndex: 50, fill: "#eeeee6", outline: null, outlineWidth: 0 },
|
||||
{ id: "center_lines", zIndex: 60, fill: "#f5be2a", outline: null, outlineWidth: 0 },
|
||||
{ id: "crosswalks", zIndex: 70, fill: "#fffff6", outline: null, outlineWidth: 0 },
|
||||
{ id: "vehicle_stop_lines", zIndex: 80, fill: "#fffff6", outline: null, outlineWidth: 0 },
|
||||
{ id: "lane_arrows_webscale", zIndex: 90, fill: "#fffff6", outline: "#2b2b28", outlineWidth: 0.015 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function splitLayers(dir, arrowScaleValue, osm) {
|
||||
const plain = JSON.parse(fs.readFileSync(path.join(dir, "plain.geojson"), "utf8"));
|
||||
const lanePolygons = JSON.parse(fs.readFileSync(path.join(dir, "lane_polygons.geojson"), "utf8"));
|
||||
@@ -410,6 +479,7 @@ function splitLayers(dir, arrowScaleValue, osm) {
|
||||
}
|
||||
if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -497,11 +567,7 @@ function pointInRing([x, y], ring) {
|
||||
}
|
||||
|
||||
function buildCrosswalks(osm) {
|
||||
const crossingNodes = [...osm.nodes.values()].filter((node) => {
|
||||
if (node.tags.highway !== "crossing") return false;
|
||||
const markings = node.tags["crossing:markings"];
|
||||
return !(markings && ["no", "none", "unmarked"].includes(markings));
|
||||
});
|
||||
const crossingNodes = markedCrossingNodes(osm);
|
||||
const clusterCenters = crossingClusters(crossingNodes);
|
||||
const stripes = emptyCollection();
|
||||
const stopLines = emptyCollection();
|
||||
@@ -527,7 +593,15 @@ function buildCrosswalks(osm) {
|
||||
return { stripes, stopLines, zones, stopLineExclusionZones };
|
||||
}
|
||||
|
||||
function crossingClusters(nodes) {
|
||||
function markedCrossingNodes(osm) {
|
||||
return [...osm.nodes.values()].filter((node) => {
|
||||
if (node.tags.highway !== "crossing") return false;
|
||||
const markings = node.tags["crossing:markings"];
|
||||
return !(markings && ["no", "none", "unmarked"].includes(markings));
|
||||
});
|
||||
}
|
||||
|
||||
function crossingNodeClusters(nodes) {
|
||||
const clusters = [];
|
||||
for (const node of nodes) {
|
||||
let cluster = clusters.find((candidate) => {
|
||||
@@ -540,6 +614,11 @@ function crossingClusters(nodes) {
|
||||
}
|
||||
cluster.push(node);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
function crossingClusters(nodes) {
|
||||
const clusters = crossingNodeClusters(nodes);
|
||||
const centers = new Map();
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.length < 2) continue;
|
||||
@@ -636,10 +715,13 @@ function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crossw
|
||||
const offset = stripeLength / 2 + 1.2;
|
||||
const candidateA = addMeters([node.lon, node.lat], roadUnit, offset, meters);
|
||||
const candidateB = addMeters([node.lon, node.lat], roadUnit, -offset, meters);
|
||||
const stopCenter = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters)
|
||||
? candidateA
|
||||
: candidateB;
|
||||
const coords = rectangleMeters(stopCenter, acrossUnit, roadUnit, crosswalkWidth + 0.8, 0.45, meters);
|
||||
const stopSide = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters)
|
||||
? 1
|
||||
: -1;
|
||||
const stopCenterOnRoad = stopSide === 1 ? candidateA : candidateB;
|
||||
const placement = stopLineLanePlacement(way, stripeLength, stopSide);
|
||||
const stopCenter = addMeters(stopCenterOnRoad, acrossUnit, placement.acrossOffset, meters);
|
||||
const coords = rectangleMeters(stopCenter, acrossUnit, roadUnit, placement.length, 0.45, meters);
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
@@ -647,13 +729,39 @@ function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crossw
|
||||
source: "crosswalk",
|
||||
crossing_node_id: node.id,
|
||||
highway: way?.tags.highway || null,
|
||||
stop_side: stopSide === 1 ? "with_way_outside" : "against_way_outside",
|
||||
},
|
||||
geometry: { type: "Polygon", coordinates: [coords] },
|
||||
};
|
||||
}
|
||||
|
||||
function pointDistanceMeters([lon, lat], point, meters) {
|
||||
return Math.hypot((lon - point.lon) * meters.lon, (lat - point.lat) * meters.lat);
|
||||
function pointDistanceMeters(pointA, point, meters) {
|
||||
const [lon, lat] = Array.isArray(pointA) ? pointA : [pointA.lon, pointA.lat];
|
||||
const px = Array.isArray(point) ? point[0] : point.lon;
|
||||
const py = Array.isArray(point) ? point[1] : point.lat;
|
||||
return Math.hypot((lon - px) * meters.lon, (lat - py) * meters.lat);
|
||||
}
|
||||
|
||||
function stopLineLanePlacement(way, roadWidth, stopSide) {
|
||||
if (isOneway(way)) {
|
||||
return { length: Math.max(2.8, roadWidth - 1.4), acrossOffset: 0 };
|
||||
}
|
||||
const halfWidth = roadWidth / 2;
|
||||
const innerGap = 0.15;
|
||||
const outerGap = 0.8;
|
||||
const laneWidth = Math.max(2.4, halfWidth - innerGap - outerGap);
|
||||
// China uses right-hand traffic. If the stop line is on the +roadUnit side,
|
||||
// the approaching traffic direction is -roadUnit, whose right side is +acrossUnit.
|
||||
const laneSide = stopSide === 1 ? 1 : -1;
|
||||
return {
|
||||
length: laneWidth,
|
||||
acrossOffset: laneSide * (innerGap + laneWidth / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function isOneway(way) {
|
||||
const value = String(way?.tags.oneway || "").toLowerCase();
|
||||
return ["yes", "true", "1"].includes(value);
|
||||
}
|
||||
|
||||
function crosswalkLengthMeters(way) {
|
||||
|
||||