From 73f0c328992e908bb16c8c41fcef03fb014e170d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:45:30 +0000 Subject: [PATCH] fix: no building is lost in the free line along a section seam ImportWorldBuildings now shares an outline when its footprint or the one-tile free line a section keeps around a building (CanPlace) reaches another section; the section importer refuses such an outline as outside its map, so it used to vanish at the seam. The section import skip reason no longer says 'Cell boundary' (buildings may cross native cells; that label was given to overlap/water/road failures) and names environment objects separately. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ECCGjSrSADcjxBmXdCBpm --- .../GeographicImporter.WorldBuildings.cs | 4 +++- src/SimplePZMapmaker.Core/GeographicImporter.cs | 2 +- tests/SimplePZMapmaker.Tests/WorldGeographyTests.cs | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs index 3597a19..65c02f9 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.WorldBuildings.cs @@ -7,7 +7,9 @@ public static (int Added,int Skipped) ImportWorldBuildings(string json,WorldProj foreach(var feature in features.Where(f=>f.Polygon&&f.Tags.ContainsKey("building"))){ token.ThrowIfCancellationRequested();var sourceId=feature.Tags.GetValueOrDefault("__pz_source");if(sourceId is not null&&existing.Contains(sourceId))continue; var rings=feature.Rings.Select(r=>r.Select(point=>{var p=projection.Project(point);return(X:p.X+reference.OriginCellX*256,Y:p.Y+reference.OriginCellY*256);}).ToList()).ToList();var ring=rings[0];var x=(int)Math.Floor(ring.Min(p=>p.X));var y=(int)Math.Floor(ring.Min(p=>p.Y));var width=Math.Max(6,(int)Math.Ceiling(ring.Max(p=>p.X)-x));var height=Math.Max(6,(int)Math.Ceiling(ring.Max(p=>p.Y)-y));if(width>64||height>64)continue; - var owners=new[]{WorldAssembler.MemberAt(world,x,y),WorldAssembler.MemberAt(world,x+width,y),WorldAssembler.MemberAt(world,x,y+height),WorldAssembler.MemberAt(world,x+width,y+height)};if(owners.Any(m=>m is null)||owners.Select(m=>m!.Id).Distinct().Count()<2)continue; + // shared when the footprint or the free line a section keeps around a building (MapGenerator.CanPlace) reaches another section; + // a section rejects such an outline as outside its map, so it would be lost at the seam + var owners=new[]{x-1,x,x+width}.SelectMany(cx=>new[]{y-1,y,y+height}.Select(cy=>WorldAssembler.MemberAt(world,cx,cy))).ToArray();if(new[]{owners[4],owners[5],owners[7],owners[8]}.Any(m=>m is null)||owners.Where(m=>m is not null).Select(m=>m!.Id).Distinct().Count()<2)continue; var tags=feature.Tags;var sourceKind=tags.GetValueOrDefault("building","yes");var kind=tags.ContainsKey("shop")?"shop":sourceKind switch{"retail" or "commercial" or "train_station"=>"shop","apartments" or "residential"=>"apartments","school" or "college" or "kindergarten"=>"school","hospital"=>"clinic","hotel"=>"hotel","industrial"=>"industrial","warehouse" or "shed" or "service"=>"warehouse","garages" or "garage"=>"garage",_=>"house"}; string? Tag(string name)=>tags.TryGetValue(name,out var value)?value[..Math.Min(100,value.Length)]:null; var b=new Building{X=x,Y=y,Width=width,Height=height,Kind=kind,Name=Tag("name")??BuildingDesigner.Names[kind],SourceId=sourceId,SourceKind=sourceKind,SourceLevels=Tag("building:levels"),SourceBasements=Tag("building:levels:underground"),SourceRoof=Tag("roof:shape")};var levels=kind is "apartments" or "school" or "clinic" or "hotel"?2:1; diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.cs b/src/SimplePZMapmaker.Core/GeographicImporter.cs index 8454fb3..3d9f7f9 100644 --- a/src/SimplePZMapmaker.Core/GeographicImporter.cs +++ b/src/SimplePZMapmaker.Core/GeographicImporter.cs @@ -107,7 +107,7 @@ public static ImportResult Import(string json,MapProject project,GeoBounds? boun } if(w>64||h>64||!MapGenerator.CanPlace(p,b)){ skipped++; - var reason=w>64||h>64?"Outline too large":b.X<1||b.Y<1||b.X+b.Width>=p.Width||b.Y+b.Height>=p.Height?"Outside the map":b.X/256!=(b.X+b.Width)/256||b.Y/256!=(b.Y+b.Height)/256?"Cell boundary":p.Buildings.Any(a=>b.Xa.X&&b.Ya.Y)?"Building overlap":"Water or road"; + var reason=w>64||h>64?"Outline too large":b.X<1||b.Y<1||b.X+b.Width>=p.Width||b.Y+b.Height>=p.Height?"Outside the map":p.Buildings.Any(a=>b.Xa.X&&b.Ya.Y)?"Building overlap":p.Decorations.Any(d=>d.Parts.Any(part=>d.X+part.Dx>=b.X&&d.X+part.Dx<=b.X+b.Width&&d.Y+part.Dy>=b.Y&&d.Y+part.Dy<=b.Y+b.Height))?"Environment object":"Water or road"; skipReasons[reason]=skipReasons.GetValueOrDefault(reason)+1;if(skippedOutlines.Count<256)skippedOutlines.Add(new(b.SourceId,b.Name,reason,x,y,w,h));continue; } p.Buildings.Add(b);imported++; diff --git a/tests/SimplePZMapmaker.Tests/WorldGeographyTests.cs b/tests/SimplePZMapmaker.Tests/WorldGeographyTests.cs index 79a1c30..1b49d69 100644 --- a/tests/SimplePZMapmaker.Tests/WorldGeographyTests.cs +++ b/tests/SimplePZMapmaker.Tests/WorldGeographyTests.cs @@ -10,6 +10,13 @@ public static class WorldGeographyTests yield return("seam repair joins roads and rivers without erasing protected objects or source",()=>{var w=WorldTests.Fixture(2);w.Maps[0].Project.Terrain[20*256+255]=Terrain.Road;w.Maps[0].Project.Terrain[30*256+255]=Terrain.Water;var before=JsonSerializer.Serialize(w);var result=WorldSeams.Repair(w,"both");Check(result.ChangedCells>0&&result.World.Maps[1].Project.Terrain[20*256]==Terrain.Road&&result.World.Maps[1].Project.Terrain[30*256]==Terrain.Water,"Road/river remains disconnected");Check(before==JsonSerializer.Serialize(w),"Repair mutated original");WorldAssembler.Validate(result.World);}); yield return("ASCII elevation keeps north-first order nodata and centre georeference",()=>{const string asc="ncols 2\nnrows 2\nxllcenter 32.005\nyllcenter 54.775\ncellsize 0.01\nNODATA_value -9999\n120 130\n100 -9999\n";var grid=ElevationSurvey.ParseAscii(asc,"EPSG:4326","Survey","CC0");Check(grid.Sample(32.005,54.785)==120&&grid.Sample(32.005,54.775)==100&&grid.Sample(32.015,54.775)==null,"Grid orientation or NODATA changed");var round=ElevationSurvey.ParseAscii(grid.ToAscii(),"EPSG:4326","Survey","CC0");Check(round.Values.SequenceEqual(grid.Values),"Export lost height samples");Reject(()=>ElevationSurvey.ParseAscii(asc,"EPSG:32636","Survey","CC0"));}); yield return("ASCII elevation rejects incomplete values and projected degrees confusion",()=>{Reject(()=>ElevationSurvey.ParseAscii("ncols 2\nnrows 2\nxllcorner 32\nyllcorner 54\ncellsize .01\n10 20 30","EPSG:4326","Survey","CC0"));Reject(()=>ElevationSurvey.ParseAscii("ncols 1\nnrows 1\nxllcorner 500000\nyllcorner 6000000\ncellsize 30\n100","EPSG:4326","Survey","CC0"));}); + yield return("an outline in the free line along a seam becomes a shared building instead of being lost",()=>{var w=WorldTests.Fixture(2);w.GeoReference=new(){Bounds=new(){West=32,East=32.02,South=54.77,North=54.79},MetresPerTile=1};var projection=new GeographicProjection(w.GeoReference,100,100); + object Way(int id,double x0,double x1){var points=new[]{(x0,80.4),(x1,80.4),(x1,94.4),(x0,94.4),(x0,80.4)}.Select(point=>{var g=projection.Geographic(point.Item1,point.Item2);return new{lon=g.Longitude,lat=g.Latitude};});return new{type="way",id,tags=new Dictionary{{"building","house"}},geometry=points};} + // 256.4..268: starts on the second section's first column, which that section keeps free (CanPlace) -> "Outside the map" there + var json=JsonSerializer.Serialize(new{elements=new[]{Way(1,256.4,268.6),Way(2,230.4,243.6)}});var notes=new List();var result=GeographicImporter.ImportWorldBuildings(json,w,42,notes); + Check(result.Added==1&&w.Buildings.Single().SourceId=="osm-way-1","Seam-margin outline was not shared, or an inner outline was");WorldAssembler.Validate(w); + var section=w.Maps[1].Project;var local=GeographicImporter.Import(JsonSerializer.Serialize(new{elements=new[]{Way(1,256.4,268.6)}}),section,null,projection:new GeographicProjection(w.GeoReference,section.WorldX,section.WorldY)); + Check(local.Project.Buildings.Count==0&&local.SkipReasons.ContainsKey("Outside the map"),"The section importer should refuse the seam-margin outline");}); yield return("OSM full footprint spanning sections becomes one common building",()=>{var w=WorldTests.Fixture(2);w.GeoReference=new(){Bounds=new(){West=32,East=32.02,South=54.77,North=54.79},MetresPerTile=1};var projection=new GeographicProjection(w.GeoReference,100,100);var points=new[]{(250d,80d),(266d,80d),(266d,94d),(250d,94d),(250d,80d)}.Select(point=>{var g=projection.Geographic(point.Item1,point.Item2);return new{lon=g.Longitude,lat=g.Latitude};});var json=JsonSerializer.Serialize(new{elements=new[]{new{type="way",id=42,tags=new Dictionary{{"building","house"},{"building:levels","2"}},geometry=points}}});var notes=new List();var result=GeographicImporter.ImportWorldBuildings(json,w,42,notes);Check(result.Added==1&&w.Buildings.Count==1&&w.Buildings[0].LevelsOrigin=="osm-levels"&&w.Buildings[0].SourceId=="osm-way-42","Boundary OSM building lost or duplicated");WorldAssembler.Validate(w);}); } static void Check(bool ok,string msg){if(!ok)throw new Exception(msg);}static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected rejection");}