diff --git a/README.md b/README.md index 5614c8d..e114562 100644 --- a/README.md +++ b/README.md @@ -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) — 变更记录 \ No newline at end of file diff --git a/blender/README.md b/blender/README.md index ecc92a7..7087dab 100644 --- a/blender/README.md +++ b/blender/README.md @@ -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. +``` \ No newline at end of file diff --git a/blender/export_cesium.py b/blender/export_cesium.py index d5babf7..e79ab6e 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -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), @@ -400,4 +353,4 @@ def export(args): if __name__ == "__main__": - export(cli_args()) + export(cli_args()) \ No newline at end of file diff --git a/blender/generate_nantaizi.py b/blender/generate_scene.py similarity index 89% rename from blender/generate_nantaizi.py rename to blender/generate_scene.py index 8728b79..759ec4a 100644 --- a/blender/generate_nantaizi.py +++ b/blender/generate_scene.py @@ -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,19 +1150,19 @@ 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__": - build(cli_args()) + build(cli_args()) \ No newline at end of file diff --git a/config/default.json b/config/default.json index 6077cd6..fe11d0b 100644 --- a/config/default.json +++ b/config/default.json @@ -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, @@ -18,4 +19,4 @@ "inferred_sidewalks": true, "osm2lanes": true } -} +} \ No newline at end of file diff --git a/config/examples/template.json b/config/examples/template.json index 6f60635..e3bb839 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -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": "" } -} +} \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 5f1f4ce..e35c2a8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. @@ -7,4 +20,4 @@ - Added [config/hanyang-block.json](/Users/que01/osm2streets-qgis-workflow/config/hanyang-block.json) for `/Users/que01/Desktop/汉阳区区块.osm`. - Added [config/examples/template.json](/Users/que01/osm2streets-qgis-workflow/config/examples/template.json) for new areas. - Updated OSM node coordinate parsing to support both single-quoted and double-quoted XML attributes. -- Verified QGIS outputs for both a smaller Overpass-style XML input and a larger JOSM-generated `.osm` input. +- Verified QGIS outputs for both a smaller Overpass-style XML input and a larger JOSM-generated `.osm` input. \ No newline at end of file diff --git a/package.json b/package.json index d0acdff..055d216 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -9,4 +9,4 @@ "dependencies": { "osm2streets-js-node": "0.1.4" } -} +} \ No newline at end of file diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index de3f530..3c72921 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -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: