diff --git a/.trellis/spec/blender/module-structure.md b/.trellis/spec/blender/module-structure.md index edc589e..5d5efbf 100644 --- a/.trellis/spec/blender/module-structure.md +++ b/.trellis/spec/blender/module-structure.md @@ -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 的经纬度) | | `catalog.py` | 纯 | `ROAD_LAYERS`、`MATERIALS`、`road_material_specs()`、`check_layers()` | | `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 参与相机取景: ```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 ``` @@ -114,6 +115,36 @@ def assemble(ring, way_id, tag, office_overrides, collection, materials): `counts["industrial_count"]`,以及写 `scene["office_override_way_ids"]`。 模块只负责 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 装配模块,不拥有图层表或计数: ```python diff --git a/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/check.jsonl b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/implement.jsonl b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/prd.md b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/prd.md new file mode 100644 index 0000000..2da1844 --- /dev/null +++ b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/prd.md @@ -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. diff --git a/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/report.md b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/report.md new file mode 100644 index 0000000..ac9cfa6 --- /dev/null +++ b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/report.md @@ -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. diff --git a/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/task.json b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/task.json new file mode 100644 index 0000000..d7de112 --- /dev/null +++ b/.trellis/tasks/08-03-fix-osm-multipolygon-buildings/task.json @@ -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": {} +} \ No newline at end of file diff --git a/blender/generate_scene.py b/blender/generate_scene.py index f111d68..0fd6144 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -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: diff --git a/blender/osmassets/building.py b/blender/osmassets/building.py index 93e4a51..17111dc 100644 --- a/blender/osmassets/building.py +++ b/blender/osmassets/building.py @@ -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 diff --git a/blender/osmassets/osm.py b/blender/osmassets/osm.py index 7385a19..897fcd4 100644 --- a/blender/osmassets/osm.py +++ b/blender/osmassets/osm.py @@ -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 diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py index 404eac3..6d41c00 100644 --- a/blender/tests/test_pure.py +++ b/blender/tests/test_pure.py @@ -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(""" + + + + + + + + + + + + + + + + + + + + +""") + 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(""" + + + + + + + + + + + + + + + +""") + 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(""" + + + + + + + + + + + + +""") + 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 diff --git a/docs/changelog.md b/docs/changelog.md index d9758bf..4175e0b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,12 @@ ## 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 生成并列 WebP 压缩产物,不进入 `all`,不覆盖默认 `.glb`。nantaizi 试验结果: `20.50 MB -> 4.80 MB`,embedded images `17.72 MB -> 2.04 MB`,nodes / meshes /