通用化项目结构: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:
172
README.md
172
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) — 变更记录
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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__":
|
||||
@@ -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,
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user