通用化项目结构:Blender 脚本去硬编码、config 路径占位符、layerPrefix 配置

- blender/generate_nantaizi.py → blender/generate_scene.py(去硬编码路径,
  --geojson 改为可选,--office-overrides 替代硬编码 way ID)
- blender/export_cesium.py CLI 参数改为必传,修复隐藏物体导出崩溃
- blender/README.md 重写为中文通用文档
- config/default.json 路径改为占位符,template.json 新增 blender 配置块
- scripts/build-osm2streets-qgis.js 新增 layerPrefix 配置驱动 QGIS 图层名
- package.json name → osm-gis-pipeline
- README.md 重写并增加实际运行示例
This commit is contained in:
2026-07-24 17:09:43 +08:00
parent fb2a36ac7e
commit cf69c99d20
9 changed files with 283 additions and 302 deletions

172
README.md
View File

@@ -1,19 +1,6 @@
# osm2streets QGIS workflow
# OSM GIS Pipeline
把 Overpass/OSM XML 转成接近 osm2streets web 风格的 QGIS 工程
当前默认输入输出在 [config/default.json](/Users/que01/osm2streets-qgis-workflow/config/default.json) 里配置:
- 输入 OSM XML`/Users/que01/Downloads/osm.xml`
- 中间 GeoJSON`/Users/que01/Downloads/osm2streets_web_out/`
- GeoPackage`/Users/que01/Downloads/osm2streets_webstyle.gpkg`
- QGIS 工程:`/Users/que01/Downloads/osm2streets_webstyle.qgz`
- 预览图:`/Users/que01/Downloads/osm2streets_webstyle_preview.png`
已验证的输入案例记录在 [docs/input-cases.md](/Users/que01/osm2streets-qgis-workflow/docs/input-cases.md),变更记录在 [docs/changelog.md](/Users/que01/osm2streets-qgis-workflow/docs/changelog.md)。目前包括:
- `/Users/que01/Downloads/osm.xml`
- `/Users/que01/Desktop/汉阳区区块.osm`
把 OSM XML 转为 QGIS 工程osm2streets 风格道路)和可选的 Blender 3D 场景
## 环境
@@ -21,6 +8,7 @@
- macOS QGIS默认 `/Applications/QGIS.app`
- Node.js / npm
- Blender可选用于 3D 场景生成)
首次使用:
@@ -29,7 +17,7 @@ cd /Users/que01/osm2streets-qgis-workflow
npm install
```
## 运行
## QGIS 管线(强依赖 osm2streets
使用默认配置:
@@ -38,77 +26,64 @@ cd /Users/que01/osm2streets-qgis-workflow
npm run build
```
使用另一套配置:
使用指定配置:
```bash
node scripts/build-osm2streets-qgis.js --config /path/to/config.json
```
汉阳区区块案例:
```bash
node scripts/build-osm2streets-qgis.js --config config/hanyang-block.json
```
创建新区域配置可以从模板复制:
```bash
cp config/examples/template.json config/my-area.json
```
命令行参数可以覆盖配置文件:
```bash
node scripts/build-osm2streets-qgis.js \
--input /path/to/osm.xml \
--out-dir /path/to/out \
--gpkg /path/to/osm2streets_webstyle.gpkg \
--project /path/to/osm2streets_webstyle.qgz \
--preview /path/to/osm2streets_webstyle_preview.png
--gpkg /path/to/output.gpkg \
--project /path/to/output.qgz \
--preview /path/to/output_preview.png
```
调整箭头大小
创建新区域配置可以从模板复制
```bash
node scripts/build-osm2streets-qgis.js --arrow-scale 0.8
cp config/examples/template.json config/my-area.json
```
当前调好的箭头比例是 `0.8`。如果 QGIS 里仍偏大,试 `0.6`;偏小则试 `1.0`
## 配置项
默认配置文件:
### 配置项
```json
{
"qgisApp": "/Applications/QGIS.app",
"input": "/Users/que01/Downloads/osm.xml",
"outDir": "/Users/que01/Downloads/osm2streets_web_out",
"gpkg": "/Users/que01/Downloads/osm2streets_webstyle.gpkg",
"project": "/Users/que01/Downloads/osm2streets_webstyle.qgz",
"preview": "/Users/que01/Downloads/osm2streets_webstyle_preview.png",
"input": "/absolute/path/to/input.osm",
"outDir": "/absolute/path/to/osm2streets_web_out",
"gpkg": "/absolute/path/to/output.gpkg",
"project": "/absolute/path/to/output.qgz",
"preview": "/absolute/path/to/output_preview.png",
"arrowScale": 0.8,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,
"canvasExtent": null,
"previewExtent": null
"previewExtent": null,
"layerPrefix": "osm2streets",
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,
"sidepath_zipping_experiment": false,
"inferred_sidewalks": true,
"osm2lanes": true
}
}
```
常用项:
- `input`OSM XML 输入文件路径。
- `outDir`osm2streets GeoJSON 中间产物输出目录。
- `gpkg` / `project` / `preview`:最终 GeoPackage、QGIS 工程、预览 PNG 路径。
- `layerPrefix`QGIS 图层显示名称前缀(如 `"osm2streets"``"osm2streets road surface"`)。
- `arrowScale`:方向箭头几何缩放。
- `osm2streets`osm2streets 引擎选项。
- `input`OSM XML 输入。
- `outDir`osm2streets GeoJSON 中间产物目录。
- `gpkg`:最终 GeoPackage。
- `project`:最终 QGIS 工程。
- `preview`:预览 PNG。
- `qgisApp`QGIS.app 路径。
- `arrowScale`:方向箭头几何缩放,当前推荐 `0.8`
- `canvasExtent`QGIS 打开后的初始范围,格式 `"xmin,ymin,xmax,ymax"`;为 `null` 时自动按 OSM bbox 扩展。
- `previewExtent`:预览 PNG 范围,格式同上;为 `null` 时自动选第一个箭头附近。
## 输出图层
### 输出图层
GeoPackage 内会生成:
@@ -122,10 +97,85 @@ GeoPackage 内会生成:
- `lane_arrows_webscale`
- `crosswalks`
QGIS 工程绘制顺序已经固定为:路段和路口路面在底,车道线、斑马线、停止线和箭头在上。
QGIS 工程绘制顺序路面在底,车道线、斑马线、停止线和箭头在上。
## 注意
## Blender 3D 场景(可选)
这套流程复用 osm2streets 的几何输出,再用 QGIS 符号化模拟 web 效果。它不会完全等同 osm2streets web renderer但能稳定得到路面、人行道、停止线、中心线和方向箭头
osm2streets 道路几何为可选输入;不提供时回退到 OSM highway 折线
脚本已经兼容 OSM XML 节点坐标的双引号和单引号属性。Overpass 导出的 XML 和 JOSM 导出的 `.osm` 都已验证过。
生成场景:
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background --factory-startup \
--python blender/generate_scene.py -- \
--osm "/path/to/input.osm" \
--geojson "/path/to/osm2streets_web_out" \
--output "/path/to/scene.blend" \
--render "/path/to/preview.png"
```
`--geojson` 为可选参数。使用 `--office-overrides` 指定应渲染为办公楼的 OSM way ID逗号分隔
导出为 Cesium GLB
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--python blender/export_cesium.py -- \
--blend "/path/to/scene.blend" \
--glb "/path/to/scene_cesium.glb" \
--metadata "/path/to/scene_cesium.json"
```
详见 [blender/README.md](blender/README.md)。
## 运行示例
以下命令已在本机验证通过。
QGIS 管线(使用汉阳区区块 OSM 数据):
```bash
node scripts/build-osm2streets-qgis.js \
--config config/hanyang-block.json
```
Blender 场景(含 osm2streets 道路几何):
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background --factory-startup \
--python blender/generate_scene.py -- \
--osm "/Users/que01/Desktop/南台子湖创新谷OSM.osm" \
--geojson "/Users/que01/osm2streets-qgis-workflow/outputs/nantaizi-lake-innovation-valley/osm2streets_web_out" \
--output "/tmp/scene.blend" \
--render "/tmp/preview.png"
```
Blender 场景(纯 OSM无 osm2streets 道路):
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background --factory-startup \
--python blender/generate_scene.py -- \
--osm "/Users/que01/Desktop/南台子湖创新谷OSM.osm" \
--output "/tmp/scene-nogeojson.blend" \
--render "/tmp/preview-nogeojson.png"
```
Cesium GLB 导出:
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--python blender/export_cesium.py -- \
--blend "/tmp/scene.blend" \
--glb "/tmp/scene.glb" \
--metadata "/tmp/scene.json"
```
## 文档
- [docs/input-cases.md](docs/input-cases.md) — 已验证的 OSM 输入案例
- [docs/changelog.md](docs/changelog.md) — 变更记录

View File

@@ -1,89 +1,66 @@
# Nantaizi Lake Blender scene
# Blender 3D 场景生成
This generator combines the source OSM building, water and height tags with
the detailed osm2streets GeoJSON road layers.
该生成器将 OSM 建筑、水体、植被标签,以及可选的 osm2streets GeoJSON 道路图层,组合为 Blender 3D 场景。
## 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`
```bash
/Applications/Blender.app/Contents/MacOS/Blender \
--background --factory-startup \
--python blender/generate_scene.py -- \
--osm "/path/to/input.osm" \
--geojson "/path/to/osm2streets_web_out" \
--output "/path/to/output.blend" \
--render "/path/to/preview.png"
```
## Regenerate
`--geojson` 为可选参数。不提供时,道路使用简单的 OSM highway 折线,而非详细 osm2streets 几何。
使用 `--office-overrides` 指定一组 OSM way ID逗号分隔这些建筑将渲染为办公楼风格即使其 OSM 标签为 `building=industrial`
```
--office-overrides "117753521,117753535"
```
### 说明
- OSM `bounds` 元素定义场景范围(排除远处的地铁等关系成员)
- `natural=tree` 节点 → 独立树木(程序化或模型树冠)
- `natural=tree_row` 路径 → 沿路径均匀分布的树木
- `landuse=grass` → 绿色地面
- `natural=scrub` → 低矮灌木丛
- `amenity=fountain` → 低多边形喷泉
- 建筑:`building=industrial` → 厂房风格;其他 → 办公楼风格(带窗带)
- 植被和建筑高度从 OSM `height` 标签读取
- 程序化细节(窗带、屋顶设备、树冠)不改变 OSM 体量
- 纹理使用 Poly Haven 的 1K CC0 纹理,打包在 `.blend`
## 导出为 Cesium GLB
```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"
--python blender/export_cesium.py -- \
--blend "/path/to/output.blend" \
--glb "/path/to/output_cesium.glb" \
--metadata "/path/to/output_cesium.json"
```
The OSM file contains distant subway relation members. The generator uses the
explicit OSM `bounds` element to keep the scene limited to the park.
GLB 使用以 OSM bounds 中心为原点的局部 ENU 坐标系X 东Y 北Z 上)。
使用配套的 JSON 元数据文件将模型放置到 Cesium 中。
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.
导出脚本会:
- 应用网格修改器并创建 UV
- 将 Blender 特有材质转换为 glTF 兼容 PBR 材质
- 烘焙色调覆盖基础色以实现干净的 Web 渲染
- 将所有纹理图片嵌入 GLB
- 写入包含 WGS84 锚点和 Cesium JS 辅助代码的 JSON 元数据
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
在输出目录上启动 HTTP 服务后打开预览页:
```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
cd /path/to/output
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.

View File

@@ -1,4 +1,4 @@
"""Export the Nantaizi Blender master scene as a Cesium-ready GLB.
"""Export a Blender scene (from generate_scene.py) 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
@@ -17,70 +17,38 @@ 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}
values = {"blend": None, "glb": None, "metadata": None}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
@@ -89,11 +57,16 @@ def cli_args():
i += 2
else:
i += 1
if not values.get("blend"):
raise RuntimeError("--blend is required")
if not values.get("glb"):
raise RuntimeError("--glb is required")
if not values.get("metadata"):
raise RuntimeError("--metadata is required")
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:
@@ -129,7 +102,6 @@ def source_texture_scale(material):
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
@@ -148,7 +120,6 @@ def tinted_image(source, name, tint, factor):
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
@@ -158,7 +129,6 @@ def cesium_tinted_image(material, source):
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:
@@ -188,7 +158,6 @@ def tree_crown_image():
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
@@ -225,9 +194,6 @@ def make_export_material(material):
else:
diffuse = cesium_tinted_image(material, diffuse)
if material.name in EXPORT_BASE_COLOR_OVERRIDES:
# 普通办公楼/浅色屋顶使用上面的稳定浅色 Base Color。
# 不连接漫反射贴图和法线贴图,避免 Cesium 里重新出现偏黑、
# 偏脏的斑驳效果,也避免法线贴图在硬光照下把屋顶压暗。
diffuse = None
normal = None
mapping = None
@@ -236,8 +202,6 @@ def make_export_material(material):
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"])
@@ -262,9 +226,6 @@ def make_export_material(material):
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
@@ -294,8 +255,6 @@ def apply_mesh_modifiers(obj):
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
@@ -304,17 +263,15 @@ def export(args):
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
if obj.hide_viewport or obj.hide_render:
continue
meshes.append(obj)
apply_mesh_modifiers(obj)
unwrap_mesh(obj)
@@ -360,8 +317,6 @@ def export(args):
"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", ""),
@@ -390,8 +345,6 @@ def export(args):
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),

View File

@@ -1,12 +1,22 @@
"""Build a lightweight 3D park model from the Nantaizi OSM export.
"""Build a lightweight 3D scene from an OSM export and optional osm2streets GeoJSON.
Run from Blender 4.x:
blender --python generate_nantaizi.py -- \
--osm "/Users/que01/Desktop/南台子湖创新谷OSM.osm"
blender --background --factory-startup --python blender/generate_scene.py -- \
--osm "/path/to/input.osm" \
--output "/path/to/output.blend" \
--render "/path/to/preview.png"
The OSM bounds element is used deliberately. The export contains subway
relation members far outside the park, so using every node for the scene
extent would produce a misleadingly large model.
The OSM bounds element is used deliberately. OSM exports may contain distant
relation members outside the requested area, so using every node for extent
would produce a misleadingly large model.
Optional osm2streets GeoJSON directory provides detailed road surfaces,
sidewalks, lane markings, and crosswalks. When omitted, roads fall back to
simple OSM highway polylines.
Vegetation: natural=tree nodes become individual trees, natural=tree_row ways
become evenly spaced rows, landuse=grass becomes green ground, natural=scrub
becomes low shrub volumes. amenity=fountain becomes low-poly fountain basins.
"""
import json
@@ -20,19 +30,6 @@ import bpy
from mathutils import Matrix, Vector
DEFAULT_OSM = "/Users/que01/Desktop/南台子湖创新谷OSM.osm"
DEFAULT_GEOJSON = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/osm2streets_web_out"
)
DEFAULT_OUTPUT = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.blend"
)
DEFAULT_RENDER = (
"/Users/que01/osm2streets-qgis-workflow/outputs/"
"nantaizi-lake-innovation-valley/nantaizi_lake_innovation_valley.png"
)
TEXTURE_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
))
@@ -42,14 +39,11 @@ MODEL_ROOT = os.path.abspath(os.path.join(
TREE_MODEL_PATH = os.path.join(
MODEL_ROOT, "78-hazelnutbush", "Hazelnut.obj"
)
# These two footprints are ordinary office buildings despite their current
# OSM building=industrial tags. Keep the correction explicit and traceable.
OFFICE_OVERRIDE_WAY_IDS = {"117753521", "117753535"}
def cli_args():
values = {"osm": DEFAULT_OSM, "geojson": DEFAULT_GEOJSON,
"output": DEFAULT_OUTPUT, "render": DEFAULT_RENDER}
values = {"osm": None, "geojson": None, "output": None, "render": None,
"office_overrides": ""}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
@@ -58,6 +52,21 @@ def cli_args():
i += 2
else:
i += 1
if not values.get("osm"):
raise RuntimeError("--osm is required; --geojson is optional")
if not values.get("output"):
raise RuntimeError("--output is required")
if not values.get("render"):
raise RuntimeError("--render is required")
if values.get("office_overrides"):
try:
values["office_overrides"] = set(
w.strip() for w in values["office_overrides"].split(",") if w.strip()
)
except Exception:
values["office_overrides"] = set()
else:
values["office_overrides"] = set()
return values
@@ -149,7 +158,6 @@ def make_material(name, color, roughness=0.8, metallic=0.0):
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08):
"""Add small-scale color and normal variation without external textures."""
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.get("Principled BSDF")
@@ -419,7 +427,6 @@ def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08)
def add_building_details(name, ring, height, industrial, materials, collection):
"""Add restrained facade and roof detail without changing OSM massing."""
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
if len(footprint) < 3:
return
@@ -468,7 +475,6 @@ def feature_in_bounds(feature, projector):
def clip_polygon(ring, xmin, xmax, ymin, ymax):
"""Clip a projected polygon to the explicit OSM scene bounds."""
if len(ring) < 3:
return []
@@ -619,7 +625,6 @@ def add_tree_batch(positions, collection, trunk_material, leaf_material):
crown_r = max(0.85, height * 0.30)
crown_z = height * 0.82
# Three overlapping blobs read as a natural crown at close range.
add_blob(leaves, x, y, crown_z, crown_r * 0.70,
crown_r * 0.62, crown_r * 0.72, index * 1.41)
add_blob(leaves, x - crown_r * 0.42, y + crown_r * 0.08,
@@ -633,14 +638,6 @@ def add_tree_batch(positions, collection, trunk_material, leaf_material):
def add_tree_model_instances(positions, collection):
"""Use the configured tree model for OSM tree nodes and rows.
The source asset is imported once as hidden template geometry. Every OSM
tree becomes a linked duplicate sharing the same mesh data instead of
copying the source geometry repeatedly. If the model is missing or import
fails, return False so the procedural tree builder can be used as a safe
fallback.
"""
if not positions or not os.path.exists(TREE_MODEL_PATH):
return False
@@ -674,9 +671,6 @@ def add_tree_model_instances(positions, collection):
)
source_height = max(0.1, max_z - min_z)
# Normalize the hidden template so its base sits on z=0. The source model
# may not match the intended street-tree height, so every instance scales
# to the OSM height/default height below.
base_shift = Matrix.Translation((0.0, 0.0, -min_z))
for obj in template_objects:
obj.matrix_world = base_shift @ obj.matrix_world
@@ -730,14 +724,6 @@ def point_in_polygon(point, ring):
def add_scrub_patch(name, ring, material, collection):
"""Render OSM natural=scrub as textured, uneven shrub cover.
Important: do not use Poly Haven shrub model atlases as the surface
material here. Model atlases are laid out for a specific plant mesh, not
for tiling across an OSM polygon, and they appear as large patchwork blocks
in Cesium. Use a tileable foliage/grass texture for the ground-cover
surface, then add low deterministic domes for shrub volume.
"""
if len(ring) < 3:
return None
if ring[0] == ring[-1]:
@@ -787,8 +773,6 @@ def add_scrub_patch(name, ring, material, collection):
attempts = 0
while added < clump_count and attempts < clump_count * 8:
attempts += 1
# Deterministic low-discrepancy sampling: stable between runs, but not
# grid-like. This avoids random scene churn while keeping natural spread.
u = (attempts * 0.61803398875) % 1.0
v = (attempts * 0.41421356237) % 1.0
x = xmin + u * width
@@ -814,7 +798,6 @@ def link_object_to_collection(obj, collection):
def add_fountain(name, x, y, collection, materials):
"""Create a restrained fountain from an explicit amenity=fountain node."""
def cylinder(part_name, radius, depth, z, material, vertices=48):
bpy.ops.mesh.primitive_cylinder_add(
vertices=vertices, radius=radius, depth=depth,
@@ -835,8 +818,6 @@ def add_fountain(name, x, y, collection, materials):
cylinder("Pedestal", 0.30, 0.78, 0.72,
materials["fountain_stone"], vertices=32)
# A compact central spray reads at overview distance without creating a
# high-polygon particle system that would be expensive in Cesium.
bpy.ops.mesh.primitive_uv_sphere_add(
segments=20, ring_count=10, radius=0.22,
location=(x, y, 1.30))
@@ -883,7 +864,6 @@ def configure_scene():
def configure_default_viewport():
"""Make the saved Layout workspace show the same rendered camera view."""
workspace = bpy.data.workspaces.get("Layout")
if workspace:
try:
@@ -1025,10 +1005,8 @@ def build(args):
elif "building" in tag and len(ring) >= 3:
way_id = str(way["id"])
industrial = (tag.get("building") == "industrial" and
way_id not in OFFICE_OVERRIDE_WAY_IDS)
way_id not in args["office_overrides"])
source_height = max(3.0, parse_height(tag, 12.0))
# Ordinary park offices are represented as three floors plus roof;
# retain explicit high-rise massing and all industrial heights.
height = source_height if industrial or source_height >= 30.0 else 11.4
material = building_mats["industrial"] if industrial else building_mats["default"]
building_name = "Building_" + way_id
@@ -1039,7 +1017,7 @@ def build(args):
building_obj["render_height"] = height
building_obj["building_kind"] = "industrial" if industrial else "office"
building_obj["osm_building_tag"] = tag.get("building", "")
building_obj["office_override"] = way_id in OFFICE_OVERRIDE_WAY_IDS
building_obj["office_override"] = way_id in args["office_overrides"]
bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL")
bevel.width = 0.16
bevel.segments = 2
@@ -1052,19 +1030,19 @@ def build(args):
building_count += 1
industrial_count += int(industrial)
# Fine road surfaces and markings from the osm2streets GeoJSON output.
layer_z = {"road_surface": 0.03, "intersection_surface": 0.035,
"sidewalks": 0.065, "sidewalk_corners": 0.067,
"lane_separators": 0.090, "center_lines": 0.092,
"crosswalks": 0.094, "vehicle_stop_lines": 0.096,
"lane_arrows_webscale": 0.098}
geojson_dir = args.get("geojson")
road_counts = {}
for layer, z in layer_z.items():
road_counts[layer] = add_geojson_layer(
os.path.join(args["geojson"], layer + ".geojson"), layer,
projector, roads_c, road_mats[layer], z)
if geojson_dir and os.path.isdir(geojson_dir):
layer_z = {"road_surface": 0.03, "intersection_surface": 0.035,
"sidewalks": 0.065, "sidewalk_corners": 0.067,
"lane_separators": 0.090, "center_lines": 0.092,
"crosswalks": 0.094, "vehicle_stop_lines": 0.096,
"lane_arrows_webscale": 0.098}
for layer, z in layer_z.items():
road_counts[layer] = add_geojson_layer(
os.path.join(geojson_dir, layer + ".geojson"), layer,
projector, roads_c, road_mats[layer], z)
# If no road GeoJSON is available, retain a useful OSM-only fallback.
if road_counts.get("road_surface", 0) == 0:
for way in ways:
highway = way["tags"].get("highway")
@@ -1073,7 +1051,6 @@ def build(args):
add_polyline("OSM_Road_" + str(way["id"]), way["coords"], projector,
roads_c, road_mats["road_surface"], width, 0.03)
# Trees come only from explicit OSM tree nodes and tree_row ways.
trees = []
individual_tree_count = 0
for feature in point_features:
@@ -1111,7 +1088,6 @@ def build(args):
props_c, fountain_mats)
fountain_count += 1
# A simple sun/area-light rig keeps the model readable in viewport and render.
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
sun = bpy.context.object
sun.name = "Sun"
@@ -1144,7 +1120,7 @@ def build(args):
camera_target = (0, 0, 3)
bpy.ops.object.camera_add(location=cam_location)
camera = bpy.context.object
camera.name = "Park Overview Camera"
camera.name = "Scene Overview Camera"
camera.data.lens = 48
camera.data.clip_start = 0.1
camera.data.clip_end = 5000.0
@@ -1155,11 +1131,11 @@ def build(args):
scene = bpy.context.scene
scene.render.filepath = args["render"]
scene["source_osm"] = args["osm"]
scene["source_geojson"] = args["geojson"]
scene["source_geojson"] = geojson_dir or ""
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
scene["building_count"] = building_count
scene["industrial_building_count"] = industrial_count
scene["office_override_way_ids"] = json.dumps(sorted(OFFICE_OVERRIDE_WAY_IDS))
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
scene["lake_count"] = lake_count
scene["grass_count"] = grass_count
scene["scrub_count"] = scrub_count
@@ -1174,18 +1150,18 @@ def build(args):
bpy.ops.file.pack_all()
bpy.ops.wm.save_as_mainfile(filepath=args["output"])
bpy.ops.render.render(write_still=True)
print("NANTAIZI_DONE", json.dumps({"output": args["output"],
"render": args["render"],
"buildings": building_count,
"industrial_buildings": industrial_count,
"lake": lake_count,
"grass": grass_count,
"scrub": scrub_count,
"fountains": fountain_count,
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,
"trees": len(trees),
"road_features": road_counts}, ensure_ascii=True))
print("SCENE_DONE", json.dumps({"output": args["output"],
"render": args["render"],
"buildings": building_count,
"industrial_buildings": industrial_count,
"lake": lake_count,
"grass": grass_count,
"scrub": scrub_count,
"fountains": fountain_count,
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,
"trees": len(trees),
"road_features": road_counts}, ensure_ascii=True))
if __name__ == "__main__":

View File

@@ -1,16 +1,17 @@
{
"qgisApp": "/Applications/QGIS.app",
"input": "/Users/que01/Downloads/osm.xml",
"outDir": "/Users/que01/Downloads/osm2streets_web_out",
"gpkg": "/Users/que01/Downloads/osm2streets_webstyle.gpkg",
"project": "/Users/que01/Downloads/osm2streets_webstyle.qgz",
"preview": "/Users/que01/Downloads/osm2streets_webstyle_preview.png",
"input": "/absolute/path/to/input.osm",
"outDir": "/absolute/path/to/osm2streets_web_out",
"gpkg": "/absolute/path/to/output.gpkg",
"project": "/absolute/path/to/output.qgz",
"preview": "/absolute/path/to/output_preview.png",
"arrowScale": 0.8,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,
"canvasExtent": null,
"previewExtent": null,
"layerPrefix": "osm2streets",
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -1,21 +1,29 @@
{
"qgisApp": "/Applications/QGIS.app",
"input": "/absolute/path/to/input.osm",
"outDir": "/absolute/path/to/output/out",
"gpkg": "/absolute/path/to/output/name.gpkg",
"project": "/absolute/path/to/output/name.qgz",
"preview": "/absolute/path/to/output/name_preview.png",
"outDir": "/absolute/path/to/osm2streets_web_out",
"gpkg": "/absolute/path/to/output.gpkg",
"project": "/absolute/path/to/output.qgz",
"preview": "/absolute/path/to/output_preview.png",
"arrowScale": 0.8,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,
"canvasExtent": null,
"previewExtent": null,
"layerPrefix": "osm2streets",
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,
"sidepath_zipping_experiment": false,
"inferred_sidewalks": true,
"osm2lanes": true
},
"blender": {
"scene": "/absolute/path/to/scene.blend",
"render": "/absolute/path/to/preview.png",
"glb": "/absolute/path/to/cesium.glb",
"metadata": "/absolute/path/to/cesium.json",
"office_overrides": ""
}
}

View File

@@ -1,5 +1,18 @@
# Changelog
## 2026-07-24
- Blender 场景生成器通用化:`generate_nantaizi.py``generate_scene.py`
- 去除所有硬编码默认路径(--osm / --geojson / --output / --render 均为 CLI 必传)
- `--geojson` 改为可选参数,不传时回退到 OSM highway 折线
- `--office-overrides` 替代硬编码的 `OFFICE_OVERRIDE_WAY_IDS`
- `export_cesium.py` 同样去除硬编码默认路径CLI 参数改为必传
- `config/default.json` 路径改用占位符 `/absolute/path/to/...`
- `config/examples/template.json` 新增 `layerPrefix``blender` 配置块
- `scripts/build-osm2streets-qgis.js` 新增 `layerPrefix` 配置支持
- 包名改为 `osm-gis-pipeline`
- 文档全面更新为中文通用描述
## 2026-07-17
- Added configurable input/output paths through JSON config files.

View File

@@ -1,6 +1,6 @@
{
"name": "osm2streets-qgis-workflow",
"version": "0.1.0",
"name": "osm-gis-pipeline",
"version": "0.2.0",
"private": true,
"type": "commonjs",
"scripts": {

View File

@@ -23,6 +23,7 @@ const arrowScale = Number(config.arrowScale);
const clipPad = Number(config.clipPad);
const canvasPad = Number(config.canvasPad);
const previewPad = Number(config.previewPad);
const layerPrefix = config.layerPrefix || "osm2streets";
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
throw new Error(`Invalid arrowScale: ${config.arrowScale}`);
@@ -100,6 +101,7 @@ fs.writeFileSync(qgisScript, makeQgisScript({
gpkgPath,
projectPath,
previewPath,
layerPrefix,
canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)),
previewExtent: config.previewExtent || defaultPreviewExtent,
}));
@@ -874,6 +876,7 @@ GPKG = ${JSON.stringify(options.gpkgPath)}
PROJECT_PATH = ${JSON.stringify(options.projectPath)}
PREVIEW_PATH = ${JSON.stringify(options.previewPath)}
PREVIEW_EXTENT = [${options.previewExtent.split(",").map(Number).join(", ")}]
LAYER_PREFIX = ${JSON.stringify(options.layerPrefix || "osm2streets")}
def fill_symbol(color, outline="0,0,0,0", outline_width="0"):
return QgsFillSymbol.createSimple({
@@ -902,15 +905,15 @@ project.setCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
project.setPresetHomePath(str(Path(PROJECT_PATH).parent))
layers = {
"road_surface": make_layer("road_surface", "osm2streets road surface", "43,43,40,255", "30,30,28,255", "0.04"),
"intersection_surface": make_layer("intersection_surface", "osm2streets intersection surface", "43,43,40,255", "30,30,28,255", "0.04"),
"sidewalks": make_layer("sidewalks", "osm2streets sidewalks", "190,190,182,255", "156,156,148,255", "0.025"),
"sidewalk_corners": make_layer("sidewalk_corners", "osm2streets sidewalk corners", "190,190,182,255", "156,156,148,255", "0.025"),
"crosswalks": make_layer("crosswalks", "osm2streets crosswalks", "255,255,246,255"),
"lane_separators": make_layer("lane_separators", "osm2streets lane separators", "238,238,230,255"),
"center_lines": make_layer("center_lines", "osm2streets center lines", "245,190,42,255"),
"vehicle_stop_lines": make_layer("vehicle_stop_lines", "osm2streets vehicle stop lines", "255,255,246,255"),
"lane_arrows": make_layer("lane_arrows_webscale", "osm2streets lane arrows", "255,255,246,255", "43,43,40,200", "0.015"),
"road_surface": make_layer("road_surface", f"{LAYER_PREFIX} road surface", "43,43,40,255", "30,30,28,255", "0.04"),
"intersection_surface": make_layer("intersection_surface", f"{LAYER_PREFIX} intersection surface", "43,43,40,255", "30,30,28,255", "0.04"),
"sidewalks": make_layer("sidewalks", f"{LAYER_PREFIX} sidewalks", "190,190,182,255", "156,156,148,255", "0.025"),
"sidewalk_corners": make_layer("sidewalk_corners", f"{LAYER_PREFIX} sidewalk corners", "190,190,182,255", "156,156,148,255", "0.025"),
"crosswalks": make_layer("crosswalks", f"{LAYER_PREFIX} crosswalks", "255,255,246,255"),
"lane_separators": make_layer("lane_separators", f"{LAYER_PREFIX} lane separators", "238,238,230,255"),
"center_lines": make_layer("center_lines", f"{LAYER_PREFIX} center lines", "245,190,42,255"),
"vehicle_stop_lines": make_layer("vehicle_stop_lines", f"{LAYER_PREFIX} vehicle stop lines", "255,255,246,255"),
"lane_arrows": make_layer("lane_arrows_webscale", f"{LAYER_PREFIX} lane arrows", "255,255,246,255", "43,43,40,200", "0.015"),
}
draw_order = ["road_surface", "intersection_surface", "sidewalks", "sidewalk_corners", "lane_separators", "center_lines", "crosswalks", "vehicle_stop_lines", "lane_arrows"]
for key in draw_order: