Fix OSM multipolygon buildings

This commit is contained in:
2026-08-03 18:15:22 +08:00
parent 37fe65b7ca
commit 95f8458ad2
11 changed files with 458 additions and 18 deletions

View File

@@ -730,9 +730,10 @@ def build(args):
focus_points.extend(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(
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["industrial_count"] += ind_added
if ring_pts:

View File

@@ -1,17 +1,130 @@
"""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.osm import parse_height
def add_details(name, ring, height, industrial, materials, collection):
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
if len(footprint) < 3:
def _open_ring(ring):
if len(ring) > 1 and ring[0] == ring[-1]:
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
glass_mat = materials["factory_glass"] if industrial else materials["glass"]
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:
band_height = min(1.8, max(0.75, height * 0.16))
band_base = max(0.9, height * 0.52)
@@ -31,15 +144,15 @@ def add_details(name, ring, height, industrial, materials, collection):
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
way_id not in office_overrides)
source_height = max(3.0, parse_height(tag, 12.0))
height = source_height if industrial or source_height >= 30.0 else 11.4
source_height, height = render_height(tag, industrial)
material = materials["industrial"] if industrial else materials["default"]
building_name = "Building_" + way_id
building_obj = make_prism(building_name, ring, 0.08, height,
material, collection)
building_obj = make_prism_with_holes(
building_name, ring, inner_rings, 0.08, height, material, collection)
if building_obj:
building_obj["osm_height"] = source_height
building_obj["render_height"] = height
@@ -51,6 +164,8 @@ def assemble(ring, way_id, tag, office_overrides, collection, materials):
bevel.segments = 2
roof_mat = (materials["industrial_roof"] if industrial
else materials["office_roof"])
add_roof(building_name, ring, height + 0.095, roof_mat, collection)
add_details(building_name, ring, height, industrial, materials, collection)
add_roof_with_holes(building_name, ring, inner_rings, height + 0.095,
roof_mat, collection)
add_details(building_name, ring, height, industrial, materials, collection,
inner_rings=inner_rings)
return 1, int(industrial), ring

View File

@@ -7,11 +7,62 @@ import math
import xml.etree.ElementTree as ET
def _element_id(element):
return element.attrib.get("id", "")
def tags(element):
return {t.attrib.get("k", ""): t.attrib.get("v", "")
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):
root = ET.parse(path).getroot()
bounds_node = root.find("bounds")
@@ -37,6 +88,7 @@ def parse_osm(path):
continue
ways = []
way_coords_by_id = {}
for way in root.findall("way"):
if way.attrib.get("action") == "delete":
continue
@@ -46,17 +98,53 @@ def parse_osm(path):
refs.append(int(ref.attrib["ref"]))
except (KeyError, ValueError):
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:
ways.append({"id": way.attrib.get("id", ""),
"coords": coords, "tags": tags(way)})
way_coords_by_id[way_id] = coords
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
def parse_height(feature_tags, default):
try:
return max(0.5, float(feature_tags.get("height", default)))
except ValueError:
except (TypeError, ValueError):
return default

View File

@@ -356,6 +356,90 @@ class ParseOsmTest(unittest.TestCase):
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):
def test_reads_key_value_children(self):
import xml.etree.ElementTree as ET