Fix OSM multipolygon buildings
This commit is contained in:
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
| 模块 | 层 | 职责 |
|
| 模块 | 层 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `osm.py` | 纯 | `parse_osm()` 读 OSM XML → (bounds, ways, points);`Projector` 局部米制投影;`parse_height()`、`tags()` |
|
| `osm.py` | 纯 | `parse_osm()` 读 OSM XML → (bounds, ways, points);building multipolygon relation 会作为 synthetic way 进入 ways;`Projector` 局部米制投影;`parse_height()`、`tags()` |
|
||||||
| `geom.py` | 纯 | 平面几何全家桶。**输入输出一律是投影后的米**,例外只有 `geometry_rings` / `feature_in_bounds`(收原始 GeoJSON 的经纬度) |
|
| `geom.py` | 纯 | 平面几何全家桶。**输入输出一律是投影后的米**,例外只有 `geometry_rings` / `feature_in_bounds`(收原始 GeoJSON 的经纬度) |
|
||||||
| `catalog.py` | 纯 | `ROAD_LAYERS`、`MATERIALS`、`road_material_specs()`、`check_layers()` |
|
| `catalog.py` | 纯 | `ROAD_LAYERS`、`MATERIALS`、`road_material_specs()`、`check_layers()` |
|
||||||
| `mesh.py` | bpy | `MeshBatch`、`make_prism`、`add_roof`、`add_wall_panel`、`add_polyline`、集合管理 |
|
| `mesh.py` | bpy | `MeshBatch`、`make_prism`、`add_roof`、`add_wall_panel`、`add_polyline`、集合管理 |
|
||||||
@@ -104,7 +104,8 @@ def assemble(name, x, y, collection, materials):
|
|||||||
`len(ring) >= 3`,并且建筑要保持原始 OSM footprint 参与相机取景:
|
`len(ring) >= 3`,并且建筑要保持原始 OSM footprint 参与相机取景:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def assemble(ring, way_id, tag, office_overrides, collection, materials):
|
def assemble(ring, way_id, tag, office_overrides, collection, materials,
|
||||||
|
inner_rings=None):
|
||||||
...
|
...
|
||||||
return added, industrial_added, ring_pts
|
return added, industrial_added, ring_pts
|
||||||
```
|
```
|
||||||
@@ -114,6 +115,36 @@ def assemble(ring, way_id, tag, office_overrides, collection, materials):
|
|||||||
`counts["industrial_count"]`,以及写 `scene["office_override_way_ids"]`。
|
`counts["industrial_count"]`,以及写 `scene["office_override_way_ids"]`。
|
||||||
模块只负责 prism / roof / windows / bevel / building custom properties。
|
模块只负责 prism / roof / windows / bevel / building custom properties。
|
||||||
|
|
||||||
|
### Building multipolygon 与 height
|
||||||
|
|
||||||
|
`parse_osm()` 会把 `type=multipolygon` 且 relation 自身带 `building=*` 的 relation
|
||||||
|
追加为 synthetic way:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"id": relation_id,
|
||||||
|
"coords": outer_ring_lon_lat,
|
||||||
|
"inner_coords": [inner_ring_lon_lat, ...],
|
||||||
|
"tags": relation_tags,
|
||||||
|
"source": "relation",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
member way 不需要带 building tags;relation-level `height` / `building` 才是事实源。
|
||||||
|
`generate_scene.py` 在 `handle_building()` 里把 `inner_coords` 投影后传给
|
||||||
|
`building.assemble(..., inner_rings=...)`。`building.py` 用 Blender
|
||||||
|
`tessellate_polygon()` 生成带洞的 prism 和 roof;没有 inner rings 时仍走原来的
|
||||||
|
`MeshBatch` / `make_prism` 路径。
|
||||||
|
|
||||||
|
建筑高度语义:
|
||||||
|
|
||||||
|
- relation / way 上有合法显式 `height`:`render_height == height`
|
||||||
|
- `building=industrial`:沿用 OSM height / 默认高度
|
||||||
|
- 普通 building 没有显式 height 且低于 tall threshold:使用办公楼视觉默认 `11.4m`
|
||||||
|
|
||||||
|
不要再把有显式 `height=8` 的普通建筑压成 `11.4m`;用户手工编辑 OSM 高度时,OSM
|
||||||
|
tag 是更高优先级的事实。
|
||||||
|
|
||||||
`roads.py` 是 road layer 装配模块,不拥有图层表或计数:
|
`roads.py` 是 road layer 装配模块,不拥有图层表或计数:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
39
.trellis/tasks/08-03-fix-osm-multipolygon-buildings/prd.md
Normal file
39
.trellis/tasks/08-03-fix-osm-multipolygon-buildings/prd.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Fix OSM multipolygon building rendering
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Fix Blender scene generation so OSM `type=multipolygon` building relations are
|
||||||
|
rendered as buildings, and explicit OSM `height` tags are respected for normal
|
||||||
|
buildings.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Parse OSM multipolygon relations whose relation tags include `building=*`.
|
||||||
|
2. Use relation-level tags, including `height`, when generating the building.
|
||||||
|
3. Support the common case of closed outer way members and closed inner way
|
||||||
|
members, as seen in nantaizi `relation -65`.
|
||||||
|
4. Keep existing way-based building rendering working.
|
||||||
|
5. Change building height semantics so an explicit `height` tag controls render
|
||||||
|
height; fallback office default `11.4m` should apply only when height is not
|
||||||
|
explicitly present and the building is not industrial / tall.
|
||||||
|
6. Validate only `nantaizi-lake-innovation-valley`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `parse_osm()` exposes the nantaizi building multipolygon relation as a
|
||||||
|
renderable building feature.
|
||||||
|
- [x] Unit tests cover relation-level building tags, outer/inner members, and
|
||||||
|
malformed / incomplete relation members.
|
||||||
|
- [x] Existing way-based building parsing tests still pass.
|
||||||
|
- [x] A relation building with `height=8` renders at `8m`, not the office
|
||||||
|
fallback `11.4m`.
|
||||||
|
- [x] nantaizi Blender generation succeeds and includes the multipolygon
|
||||||
|
building in counts / scene digest.
|
||||||
|
- [x] No hanyang validation is required unless requested.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Concrete reproduction in current input:
|
||||||
|
`relation -65` has `building=yes`, `height=8`, `type=multipolygon`, outer way
|
||||||
|
`-2948`, and inner way `-2952`. The member ways carry no building tags, so the
|
||||||
|
current way-only dispatcher skips it.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Multipolygon building fix report
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The edited nantaizi OSM has `relation -65` with:
|
||||||
|
|
||||||
|
- `type=multipolygon`
|
||||||
|
- `building=yes`
|
||||||
|
- `height=8`
|
||||||
|
- outer way `-2948`
|
||||||
|
- inner way `-2952`
|
||||||
|
|
||||||
|
The member ways have no building tags. `parse_osm()` only exposed ways, and
|
||||||
|
`generate_scene.py` only dispatched ways, so the relation was skipped.
|
||||||
|
|
||||||
|
The height issue had a second cause: ordinary non-industrial buildings below
|
||||||
|
`30m` were forced to the office fallback render height `11.4m`, even when OSM
|
||||||
|
had an explicit `height`.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- `parse_osm()` now exposes building multipolygon relations as synthetic
|
||||||
|
building features with relation-level tags and `inner_coords`.
|
||||||
|
- Open relation member way fragments can be stitched into closed rings; malformed
|
||||||
|
relation leftovers are skipped without aborting parsing.
|
||||||
|
- `generate_scene.py` passes projected `inner_rings` to building assembly.
|
||||||
|
- `building.py` creates prism and roof meshes with holes using Blender
|
||||||
|
tessellation.
|
||||||
|
- Explicit OSM `height` now controls render height. The `11.4m` office fallback
|
||||||
|
applies only when height is not explicit.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python3 -m unittest discover blender/tests`
|
||||||
|
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender`
|
||||||
|
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium`
|
||||||
|
- `node scripts/glb-digest.js outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.glb`
|
||||||
|
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-03-fix-osm-multipolygon-buildings`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
Confirmed `Building_-65` in scene and GLB:
|
||||||
|
|
||||||
|
- `osm_height=8`
|
||||||
|
- `render_height=8`
|
||||||
|
- GLB contains `Building_-65`, `Building_-65_Roof`, and
|
||||||
|
`Building_-65_Windows`
|
||||||
|
|
||||||
|
User verified the result visually in preview.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "fix-osm-multipolygon-buildings",
|
||||||
|
"name": "fix-osm-multipolygon-buildings",
|
||||||
|
"title": "Fix OSM multipolygon building rendering",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "dingkang",
|
||||||
|
"assignee": "dingkang",
|
||||||
|
"createdAt": "2026-08-03",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -730,9 +730,10 @@ def build(args):
|
|||||||
focus_points.extend(ring)
|
focus_points.extend(ring)
|
||||||
|
|
||||||
def handle_building(way, tag, ring):
|
def handle_building(way, tag, ring):
|
||||||
|
inner_rings = [projector.ring(coords) for coords in way.get("inner_coords", [])]
|
||||||
added, ind_added, ring_pts = _building.assemble(
|
added, ind_added, ring_pts = _building.assemble(
|
||||||
ring, str(way["id"]), tag, args["office_overrides"],
|
ring, str(way["id"]), tag, args["office_overrides"],
|
||||||
buildings_c, building_mats)
|
buildings_c, building_mats, inner_rings=inner_rings)
|
||||||
counts["building_count"] += added
|
counts["building_count"] += added
|
||||||
counts["industrial_count"] += ind_added
|
counts["industrial_count"] += ind_added
|
||||||
if ring_pts:
|
if ring_pts:
|
||||||
|
|||||||
@@ -1,17 +1,130 @@
|
|||||||
"""Building feature assembly (`building=*`)."""
|
"""Building feature assembly (`building=*`)."""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from mathutils import Vector
|
||||||
|
from mathutils.geometry import tessellate_polygon
|
||||||
|
|
||||||
from osmassets.mesh import MeshBatch, add_roof, add_wall_panel, make_prism
|
from osmassets.mesh import MeshBatch, add_roof, add_wall_panel, make_prism
|
||||||
from osmassets.osm import parse_height
|
from osmassets.osm import parse_height
|
||||||
|
|
||||||
|
|
||||||
def add_details(name, ring, height, industrial, materials, collection):
|
def _open_ring(ring):
|
||||||
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
if len(ring) > 1 and ring[0] == ring[-1]:
|
||||||
if len(footprint) < 3:
|
return ring[:-1]
|
||||||
|
return ring
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_rings(outer, inner_rings):
|
||||||
|
rings = [_open_ring(outer)]
|
||||||
|
rings.extend(_open_ring(ring) for ring in (inner_rings or []))
|
||||||
|
return [ring for ring in rings if len(ring) >= 3]
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_vertices(rings, z):
|
||||||
|
vertices = []
|
||||||
|
for ring in rings:
|
||||||
|
vertices.extend((x, y, z) for x, y in ring)
|
||||||
|
return vertices
|
||||||
|
|
||||||
|
|
||||||
|
def _ring_offsets(rings):
|
||||||
|
offsets = []
|
||||||
|
offset = 0
|
||||||
|
for ring in rings:
|
||||||
|
offsets.append(offset)
|
||||||
|
offset += len(ring)
|
||||||
|
return offsets
|
||||||
|
|
||||||
|
|
||||||
|
def _tessellated_faces(rings, offset=0, reverse=False):
|
||||||
|
polygons = [[Vector((x, y, 0.0)) for x, y in ring] for ring in rings]
|
||||||
|
faces = []
|
||||||
|
for triangle in tessellate_polygon(polygons):
|
||||||
|
face = tuple(offset + index for index in triangle)
|
||||||
|
faces.append(tuple(reversed(face)) if reverse else face)
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
|
def _side_faces(rings, offsets, top_offset):
|
||||||
|
faces = []
|
||||||
|
for ring_index, ring in enumerate(rings):
|
||||||
|
offset = offsets[ring_index]
|
||||||
|
for i in range(len(ring)):
|
||||||
|
j = (i + 1) % len(ring)
|
||||||
|
if ring_index == 0:
|
||||||
|
faces.append((offset + i, offset + j,
|
||||||
|
top_offset + offset + j, top_offset + offset + i))
|
||||||
|
else:
|
||||||
|
# Inner-ring walls face the courtyard / void, opposite to the
|
||||||
|
# outer shell.
|
||||||
|
faces.append((offset + i, top_offset + offset + i,
|
||||||
|
top_offset + offset + j, offset + j))
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
|
def make_prism_with_holes(name, outer, inner_rings, base, height, material, collection):
|
||||||
|
rings = _valid_rings(outer, inner_rings)
|
||||||
|
if not rings:
|
||||||
|
return None
|
||||||
|
if len(rings) == 1:
|
||||||
|
return make_prism(name, rings[0], base, height, material, collection)
|
||||||
|
|
||||||
|
base_vertices = _flat_vertices(rings, base)
|
||||||
|
top_vertices = _flat_vertices(rings, base + height)
|
||||||
|
top_offset = len(base_vertices)
|
||||||
|
offsets = _ring_offsets(rings)
|
||||||
|
faces = []
|
||||||
|
faces.extend(_tessellated_faces(rings, reverse=True))
|
||||||
|
faces.extend(_tessellated_faces(rings, offset=top_offset))
|
||||||
|
faces.extend(_side_faces(rings, offsets, top_offset))
|
||||||
|
mesh = bpy.data.meshes.new(name + "Mesh")
|
||||||
|
mesh.from_pydata(base_vertices + top_vertices, [], faces)
|
||||||
|
mesh.materials.append(material)
|
||||||
|
mesh.update()
|
||||||
|
obj = bpy.data.objects.new(name, mesh)
|
||||||
|
collection.objects.link(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def add_roof_with_holes(name, outer, inner_rings, z, material, collection):
|
||||||
|
rings = _valid_rings(outer, inner_rings)
|
||||||
|
if not rings:
|
||||||
|
return None
|
||||||
|
if len(rings) == 1:
|
||||||
|
return add_roof(name, rings[0], z, material, collection)
|
||||||
|
|
||||||
|
mesh = bpy.data.meshes.new(name + "_RoofMesh")
|
||||||
|
mesh.from_pydata(_flat_vertices(rings, z), [], _tessellated_faces(rings))
|
||||||
|
mesh.materials.append(material)
|
||||||
|
mesh.update()
|
||||||
|
obj = bpy.data.objects.new(name + "_Roof", mesh)
|
||||||
|
collection.objects.link(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def explicit_height(tag):
|
||||||
|
if "height" not in tag:
|
||||||
|
return None
|
||||||
|
return parse_height(tag, None)
|
||||||
|
|
||||||
|
|
||||||
|
def render_height(tag, industrial):
|
||||||
|
explicit = explicit_height(tag)
|
||||||
|
source_height = max(3.0, explicit if explicit is not None else parse_height(tag, 12.0))
|
||||||
|
height = source_height if industrial or explicit is not None or source_height >= 30.0 else 11.4
|
||||||
|
return source_height, height
|
||||||
|
|
||||||
|
|
||||||
|
def add_details(name, ring, height, industrial, materials, collection, inner_rings=None):
|
||||||
|
footprints = _valid_rings(ring, inner_rings)
|
||||||
|
if not footprints:
|
||||||
return
|
return
|
||||||
glass_mat = materials["factory_glass"] if industrial else materials["glass"]
|
glass_mat = materials["factory_glass"] if industrial else materials["glass"]
|
||||||
|
|
||||||
glass_batch = MeshBatch(name + "_Windows", collection, glass_mat)
|
glass_batch = MeshBatch(name + "_Windows", collection, glass_mat)
|
||||||
edges = list(zip(footprint, footprint[1:] + footprint[:1]))
|
edges = []
|
||||||
|
for footprint in footprints:
|
||||||
|
edges.extend(zip(footprint, footprint[1:] + footprint[:1]))
|
||||||
if industrial:
|
if industrial:
|
||||||
band_height = min(1.8, max(0.75, height * 0.16))
|
band_height = min(1.8, max(0.75, height * 0.16))
|
||||||
band_base = max(0.9, height * 0.52)
|
band_base = max(0.9, height * 0.52)
|
||||||
@@ -31,15 +144,15 @@ def add_details(name, ring, height, industrial, materials, collection):
|
|||||||
glass_batch.finish()
|
glass_batch.finish()
|
||||||
|
|
||||||
|
|
||||||
def assemble(ring, way_id, tag, office_overrides, collection, materials):
|
def assemble(ring, way_id, tag, office_overrides, collection, materials,
|
||||||
|
inner_rings=None):
|
||||||
industrial = (tag.get("building") == "industrial" and
|
industrial = (tag.get("building") == "industrial" and
|
||||||
way_id not in office_overrides)
|
way_id not in office_overrides)
|
||||||
source_height = max(3.0, parse_height(tag, 12.0))
|
source_height, height = render_height(tag, industrial)
|
||||||
height = source_height if industrial or source_height >= 30.0 else 11.4
|
|
||||||
material = materials["industrial"] if industrial else materials["default"]
|
material = materials["industrial"] if industrial else materials["default"]
|
||||||
building_name = "Building_" + way_id
|
building_name = "Building_" + way_id
|
||||||
building_obj = make_prism(building_name, ring, 0.08, height,
|
building_obj = make_prism_with_holes(
|
||||||
material, collection)
|
building_name, ring, inner_rings, 0.08, height, material, collection)
|
||||||
if building_obj:
|
if building_obj:
|
||||||
building_obj["osm_height"] = source_height
|
building_obj["osm_height"] = source_height
|
||||||
building_obj["render_height"] = height
|
building_obj["render_height"] = height
|
||||||
@@ -51,6 +164,8 @@ def assemble(ring, way_id, tag, office_overrides, collection, materials):
|
|||||||
bevel.segments = 2
|
bevel.segments = 2
|
||||||
roof_mat = (materials["industrial_roof"] if industrial
|
roof_mat = (materials["industrial_roof"] if industrial
|
||||||
else materials["office_roof"])
|
else materials["office_roof"])
|
||||||
add_roof(building_name, ring, height + 0.095, roof_mat, collection)
|
add_roof_with_holes(building_name, ring, inner_rings, height + 0.095,
|
||||||
add_details(building_name, ring, height, industrial, materials, collection)
|
roof_mat, collection)
|
||||||
|
add_details(building_name, ring, height, industrial, materials, collection,
|
||||||
|
inner_rings=inner_rings)
|
||||||
return 1, int(industrial), ring
|
return 1, int(industrial), ring
|
||||||
|
|||||||
@@ -7,11 +7,62 @@ import math
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def _element_id(element):
|
||||||
|
return element.attrib.get("id", "")
|
||||||
|
|
||||||
|
|
||||||
def tags(element):
|
def tags(element):
|
||||||
return {t.attrib.get("k", ""): t.attrib.get("v", "")
|
return {t.attrib.get("k", ""): t.attrib.get("v", "")
|
||||||
for t in element.findall("tag")}
|
for t in element.findall("tag")}
|
||||||
|
|
||||||
|
|
||||||
|
def _coords_from_refs(refs, nodes):
|
||||||
|
return [nodes[r] for r in refs if r in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
def _open_ring(coords):
|
||||||
|
if len(coords) > 1 and coords[0] == coords[-1]:
|
||||||
|
return coords[:-1]
|
||||||
|
return coords
|
||||||
|
|
||||||
|
|
||||||
|
def _is_closed_ring(coords):
|
||||||
|
return len(coords) >= 4 and coords[0] == coords[-1] and len(_open_ring(coords)) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def _join_member_rings(members):
|
||||||
|
"""Build closed rings from relation member coordinate runs.
|
||||||
|
|
||||||
|
Handles the common OSM multipolygon cases: members are either already
|
||||||
|
closed ways, or open way fragments whose endpoints can be stitched together.
|
||||||
|
Malformed leftovers are dropped rather than aborting the whole import.
|
||||||
|
"""
|
||||||
|
rings = []
|
||||||
|
pending = [list(member) for member in members if len(member) >= 2]
|
||||||
|
while pending:
|
||||||
|
ring = pending.pop(0)
|
||||||
|
changed = True
|
||||||
|
while not _is_closed_ring(ring) and changed:
|
||||||
|
changed = False
|
||||||
|
for index, candidate in enumerate(pending):
|
||||||
|
if ring[-1] == candidate[0]:
|
||||||
|
ring.extend(candidate[1:])
|
||||||
|
elif ring[-1] == candidate[-1]:
|
||||||
|
ring.extend(reversed(candidate[:-1]))
|
||||||
|
elif ring[0] == candidate[-1]:
|
||||||
|
ring = candidate[:-1] + ring
|
||||||
|
elif ring[0] == candidate[0]:
|
||||||
|
ring = list(reversed(candidate[1:])) + ring
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
pending.pop(index)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
if _is_closed_ring(ring):
|
||||||
|
rings.append(ring)
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
def parse_osm(path):
|
def parse_osm(path):
|
||||||
root = ET.parse(path).getroot()
|
root = ET.parse(path).getroot()
|
||||||
bounds_node = root.find("bounds")
|
bounds_node = root.find("bounds")
|
||||||
@@ -37,6 +88,7 @@ def parse_osm(path):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ways = []
|
ways = []
|
||||||
|
way_coords_by_id = {}
|
||||||
for way in root.findall("way"):
|
for way in root.findall("way"):
|
||||||
if way.attrib.get("action") == "delete":
|
if way.attrib.get("action") == "delete":
|
||||||
continue
|
continue
|
||||||
@@ -46,17 +98,53 @@ def parse_osm(path):
|
|||||||
refs.append(int(ref.attrib["ref"]))
|
refs.append(int(ref.attrib["ref"]))
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
pass
|
pass
|
||||||
coords = [nodes[r] for r in refs if r in nodes]
|
coords = _coords_from_refs(refs, nodes)
|
||||||
|
way_id = _element_id(way)
|
||||||
if len(coords) >= 2:
|
if len(coords) >= 2:
|
||||||
ways.append({"id": way.attrib.get("id", ""),
|
way_coords_by_id[way_id] = coords
|
||||||
"coords": coords, "tags": tags(way)})
|
way_tags = tags(way)
|
||||||
|
if len(coords) >= 2:
|
||||||
|
ways.append({"id": way_id, "coords": coords, "tags": way_tags})
|
||||||
|
for relation in root.findall("relation"):
|
||||||
|
if relation.attrib.get("action") == "delete":
|
||||||
|
continue
|
||||||
|
relation_tags = tags(relation)
|
||||||
|
if relation_tags.get("type") != "multipolygon" or "building" not in relation_tags:
|
||||||
|
continue
|
||||||
|
outer_members = []
|
||||||
|
inner_members = []
|
||||||
|
for member in relation.findall("member"):
|
||||||
|
if member.attrib.get("type") != "way":
|
||||||
|
continue
|
||||||
|
coords = way_coords_by_id.get(member.attrib.get("ref", ""))
|
||||||
|
if not coords:
|
||||||
|
continue
|
||||||
|
role = member.attrib.get("role", "")
|
||||||
|
if role == "inner":
|
||||||
|
inner_members.append(coords)
|
||||||
|
elif role in ("", "outer"):
|
||||||
|
outer_members.append(coords)
|
||||||
|
outer_rings = _join_member_rings(outer_members)
|
||||||
|
if not outer_rings:
|
||||||
|
continue
|
||||||
|
inner_rings = _join_member_rings(inner_members)
|
||||||
|
relation_id = _element_id(relation)
|
||||||
|
for index, outer in enumerate(outer_rings):
|
||||||
|
synthetic_id = relation_id if len(outer_rings) == 1 else f"{relation_id}:{index + 1}"
|
||||||
|
ways.append({
|
||||||
|
"id": synthetic_id,
|
||||||
|
"coords": outer,
|
||||||
|
"inner_coords": inner_rings,
|
||||||
|
"tags": relation_tags,
|
||||||
|
"source": "relation",
|
||||||
|
})
|
||||||
return bounds, ways, point_features
|
return bounds, ways, point_features
|
||||||
|
|
||||||
|
|
||||||
def parse_height(feature_tags, default):
|
def parse_height(feature_tags, default):
|
||||||
try:
|
try:
|
||||||
return max(0.5, float(feature_tags.get("height", default)))
|
return max(0.5, float(feature_tags.get("height", default)))
|
||||||
except ValueError:
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,90 @@ class ParseOsmTest(unittest.TestCase):
|
|||||||
os.unlink(handle.name)
|
os.unlink(handle.name)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseOsmMultipolygonTest(unittest.TestCase):
|
||||||
|
def parse_text(self, text):
|
||||||
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
||||||
|
encoding="utf-8")
|
||||||
|
handle.write(text)
|
||||||
|
handle.close()
|
||||||
|
try:
|
||||||
|
return parse_osm(handle.name)
|
||||||
|
finally:
|
||||||
|
os.unlink(handle.name)
|
||||||
|
|
||||||
|
def test_building_relation_uses_relation_tags_and_members(self):
|
||||||
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<osm version='0.6'>
|
||||||
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
||||||
|
<node id='1' lon='114.001' lat='30.001'/>
|
||||||
|
<node id='2' lon='114.004' lat='30.001'/>
|
||||||
|
<node id='3' lon='114.004' lat='30.004'/>
|
||||||
|
<node id='4' lon='114.001' lat='30.004'/>
|
||||||
|
<node id='5' lon='114.002' lat='30.002'/>
|
||||||
|
<node id='6' lon='114.003' lat='30.002'/>
|
||||||
|
<node id='7' lon='114.003' lat='30.003'/>
|
||||||
|
<node id='8' lon='114.002' lat='30.003'/>
|
||||||
|
<way id='outer'><nd ref='1'/><nd ref='2'/><nd ref='3'/><nd ref='4'/><nd ref='1'/></way>
|
||||||
|
<way id='inner'><nd ref='5'/><nd ref='6'/><nd ref='7'/><nd ref='8'/><nd ref='5'/></way>
|
||||||
|
<relation id='65'>
|
||||||
|
<member type='way' ref='outer' role='outer'/>
|
||||||
|
<member type='way' ref='inner' role='inner'/>
|
||||||
|
<tag k='type' v='multipolygon'/>
|
||||||
|
<tag k='building' v='yes'/>
|
||||||
|
<tag k='height' v='8'/>
|
||||||
|
</relation>
|
||||||
|
</osm>
|
||||||
|
""")
|
||||||
|
relation_buildings = [way for way in ways if way["id"] == "65"]
|
||||||
|
self.assertEqual(len(relation_buildings), 1)
|
||||||
|
building = relation_buildings[0]
|
||||||
|
self.assertEqual(building["tags"]["building"], "yes")
|
||||||
|
self.assertEqual(building["tags"]["height"], "8")
|
||||||
|
self.assertEqual(building["source"], "relation")
|
||||||
|
self.assertEqual(len(building["coords"]), 5)
|
||||||
|
self.assertEqual(len(building["inner_coords"]), 1)
|
||||||
|
self.assertEqual(len(building["inner_coords"][0]), 5)
|
||||||
|
|
||||||
|
def test_open_outer_members_are_stitched(self):
|
||||||
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<osm version='0.6'>
|
||||||
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
||||||
|
<node id='1' lon='114.001' lat='30.001'/>
|
||||||
|
<node id='2' lon='114.004' lat='30.001'/>
|
||||||
|
<node id='3' lon='114.004' lat='30.004'/>
|
||||||
|
<node id='4' lon='114.001' lat='30.004'/>
|
||||||
|
<way id='a'><nd ref='1'/><nd ref='2'/><nd ref='3'/></way>
|
||||||
|
<way id='b'><nd ref='3'/><nd ref='4'/><nd ref='1'/></way>
|
||||||
|
<relation id='66'>
|
||||||
|
<member type='way' ref='a' role='outer'/>
|
||||||
|
<member type='way' ref='b' role='outer'/>
|
||||||
|
<tag k='type' v='multipolygon'/>
|
||||||
|
<tag k='building' v='yes'/>
|
||||||
|
</relation>
|
||||||
|
</osm>
|
||||||
|
""")
|
||||||
|
building = [way for way in ways if way["id"] == "66"][0]
|
||||||
|
self.assertEqual(len(building["coords"]), 5)
|
||||||
|
self.assertEqual(building["coords"][0], building["coords"][-1])
|
||||||
|
|
||||||
|
def test_incomplete_relation_member_is_skipped_not_fatal(self):
|
||||||
|
_, ways, _ = self.parse_text("""<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<osm version='0.6'>
|
||||||
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
||||||
|
<node id='1' lon='114.001' lat='30.001'/>
|
||||||
|
<node id='2' lon='114.004' lat='30.001'/>
|
||||||
|
<way id='broken'><nd ref='1'/><nd ref='2'/></way>
|
||||||
|
<relation id='67'>
|
||||||
|
<member type='way' ref='broken' role='outer'/>
|
||||||
|
<member type='way' ref='missing' role='outer'/>
|
||||||
|
<tag k='type' v='multipolygon'/>
|
||||||
|
<tag k='building' v='yes'/>
|
||||||
|
</relation>
|
||||||
|
</osm>
|
||||||
|
""")
|
||||||
|
self.assertEqual([way["id"] for way in ways], ["broken"])
|
||||||
|
|
||||||
|
|
||||||
class TagsTest(unittest.TestCase):
|
class TagsTest(unittest.TestCase):
|
||||||
def test_reads_key_value_children(self):
|
def test_reads_key_value_children(self):
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## 2026-08-03
|
## 2026-08-03
|
||||||
|
|
||||||
|
- 修复 OSM `type=multipolygon` building relation 不渲染的问题:`parse_osm()` 现在会把
|
||||||
|
relation-level `building=*` 合成为可分发的 building feature,并保留 `inner` 成员供
|
||||||
|
`building.py` 用 Blender tessellation 生成带洞 prism / roof。nantaizi 当前
|
||||||
|
`relation -65`(outer `-2948`、inner `-2952`、`height=8`)已进入 Blender/Cesium
|
||||||
|
产物,GLB extras 为 `osm_height=8` / `render_height=8`。同时调整普通建筑高度语义:
|
||||||
|
有显式 `height` 时尊重 OSM;没有显式高度时才使用办公楼默认 `11.4m`。
|
||||||
- 新增显式 `compress` 阶段与 `scripts/compress-glb.js`,用于从已有 Cesium GLB 生成并列
|
- 新增显式 `compress` 阶段与 `scripts/compress-glb.js`,用于从已有 Cesium GLB 生成并列
|
||||||
WebP 压缩产物,不进入 `all`,不覆盖默认 `<area-id>.glb`。nantaizi 试验结果:
|
WebP 压缩产物,不进入 `all`,不覆盖默认 `<area-id>.glb`。nantaizi 试验结果:
|
||||||
`20.50 MB -> 4.80 MB`,embedded images `17.72 MB -> 2.04 MB`,nodes / meshes /
|
`20.50 MB -> 4.80 MB`,embedded images `17.72 MB -> 2.04 MB`,nodes / meshes /
|
||||||
|
|||||||
Reference in New Issue
Block a user