diff --git a/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs b/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs
new file mode 100644
index 0000000..d60eb3a
--- /dev/null
+++ b/src/SimplePZMapmaker.Core/GeographicImporter.Fences.cs
@@ -0,0 +1,95 @@
+using System.Globalization;
+using System.Text.Json;
+namespace SimplePZMapmaker.Core;
+
+/// barrier=fence|wall lines become fence lines (docs/TRANSFER-CONTRACT.md, section 4): the projected path is walked along
+/// cell edges (a diagonal becomes a staircase), pz:fence picks a style, otherwise fence_type picks a vanilla look; pz:gates and
+/// barrier=gate points on the line become gates.
+public static partial class GeographicImporter
+{
+ public static readonly FenceStyle[] DefaultFenceStyles=[
+ new(){Id="vanilla-sheet",W="fencing_01_40",N="fencing_01_41",NW="fencing_01_42"},
+ new(){Id="vanilla-planks",W="fencing_01_10",N="fencing_01_9",NW="fencing_01_12"},
+ new(){Id="vanilla-picket",W="fencing_01_4",N="fencing_01_5",NW="fencing_01_6"},
+ new(){Id="vanilla-wire",W="fencing_01_20",N="fencing_01_21",NW="fencing_01_28"}];
+ /// The solid wooden wicket of the base game: closed W, closed N.
+ public static readonly GateStyle DefaultWicket=new(){Id="vanilla-wicket",Width=1,ClosedW=["fixtures_doors_fences_01_12"],ClosedN=["fixtures_doors_fences_01_13"]};
+ static readonly HashSet FenceBarriers=["fence","wall","city_wall","retaining_wall"];
+ static readonly HashSet GateBarriers=["gate","wicket_gate","swing_gate","lift_gate","sliding_gate","hampshire_gate"];
+
+ static string DefaultFenceStyle(Dictionary tags)=>tags.GetValueOrDefault("fence_type") switch{
+ "metal_sheet" or "sheet" or "corrugated" or "metal_bars" or "panel"=>"vanilla-sheet",
+ "chain_link" or "wire" or "mesh" or "barbed_wire" or "electric"=>"vanilla-wire",
+ "pickets" or "picket" or "split_rail" or "railing"=>"vanilla-picket",
+ _=>tags.GetValueOrDefault("barrier") is "wall" or "city_wall" or "retaining_wall"?"vanilla-sheet":"vanilla-planks"};
+
+ /// Walks a projected polyline over grid vertices with unit steps and merges them into horizontal/vertical runs.
+ public static List FenceStaircase(IReadOnlyList<(double X,double Y)> path){
+ var result=new List();if(path.Count==0)return result;
+ int x=(int)Math.Round(path[0].X),y=(int)Math.Round(path[0].Y);result.Add(new(x,y));long steps=0;
+ void Step(int nx,int ny){if(++steps>FenceLines.MaximumEdges)throw new ArgumentException("A fence path exceeds the edge budget.");
+ if(result.Count>=2){var a=result[^2];var b=result[^1];if((a.X==b.X&&b.X==nx&&Math.Sign(b.Y-a.Y)==Math.Sign(ny-b.Y))||(a.Y==b.Y&&b.Y==ny&&Math.Sign(b.X-a.X)==Math.Sign(nx-b.X))){result[^1]=new(nx,ny);return;}}
+ if(result.Count>=2&&result[^2]==new FencePoint(nx,ny)){result.RemoveAt(result.Count-1);return;} // a step straight back cancels the previous one
+ result.Add(new(nx,ny));}
+ for(var i=1;i transformed){
+ var lines=new List();var droppedGates=0;var skippedLines=0;
+ var gatePoints=transformed.Where(f=>!f.Polygon&&f.Rings.Count==1&&f.Rings[0].Count==1&&GateBarriers.Contains(f.Tags.GetValueOrDefault("barrier",""))).Select(f=>f.Rings[0][0]).ToList();
+ var mpt=p.MetresPerTile??1;
+ foreach(var f in transformed.Where(f=>FenceBarriers.Contains(f.Tags.GetValueOrDefault("barrier",""))&&f.Rings.Count>0)){
+ var sourceId=f.Tags.GetValueOrDefault("__pz_source");
+ var requested=f.Tags.GetValueOrDefault("pz:fence");var style=requested is not null&&p.FenceStyles.Any(s=>s.Id==requested)?requested:DefaultFenceStyle(f.Tags);
+ foreach(var (ring,index) in f.Rings.Select((r,i)=>(r,i))){
+ if(ring.Max(v=>v.X)<0||ring.Max(v=>v.Y)<0||ring.Min(v=>v.X)>p.Width||ring.Min(v=>v.Y)>p.Height){skippedLines++;continue;}
+ List points;try{points=FenceStaircase(ring);}catch(ArgumentException){skippedLines++;continue;}
+ if(points.Count<2||points.Count>FenceLines.MaximumPoints){skippedLines++;continue;}
+ var line=new FenceLine{Id="geo-fence-"+Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes((sourceId??string.Join(";",ring.Select(v=>v.X.ToString("R",CultureInfo.InvariantCulture)+","+v.Y.ToString("R",CultureInfo.InvariantCulture))))+"/"+index))).ToLowerInvariant()[..24],
+ Style=style,Points=points,SourceId=sourceId,SourceTags=f.Tags.Where(v=>v.Key.Length<=100&&!v.Key.StartsWith("__",StringComparison.Ordinal)).Take(47).ToDictionary(v=>v.Key,v=>v.Value[..Math.Min(300,v.Value.Length)])};
+ if(f.Tags.TryGetValue("pz:posts",out var posts)&&int.TryParse(posts,out var spacing)&&spacing is >=0 and <=64)line.PostSpacing=spacing;
+ var source=f.OriginalRings![index];line.SourcePath=(source.Count<=512?source:Enumerable.Range(0,512).Select(i=>source[(int)((long)i*(source.Count-1)/511)]).ToList()).Select(v=>new SourceOutlinePoint(v.X,v.Y)).ToList();
+ // pz:gates: [{"at": metres along the source line, "width": edges, "style": gate style, "opens": "left"|"right"}]
+ if(index==0&&f.Tags.TryGetValue("pz:gates",out var gatesJson)){
+ try{foreach(var g in JsonDocument.Parse(gatesJson).RootElement.EnumerateArray()){
+ var at=g.TryGetProperty("at",out var a)?a.GetDouble():0;var gateStyle=g.TryGetProperty("style",out var s)?s.GetString():null;
+ if(gateStyle is null||p.GateStyles.All(x=>x.Id!=gateStyle))gateStyle=DefaultWicket.Id;
+ if(NearestEdge(points,PointAlong(ring,at/mpt),2) is not {} gateEdge){droppedGates++;continue;}
+ line.Gates.Add(new(){At=gateEdge,Style=gateStyle!,OpensTo=g.TryGetProperty("opens",out var o)&&o.GetString()=="left"?"left":"right"});}}
+ catch(Exception e) when(e is JsonException or InvalidOperationException or FormatException){droppedGates++;}
+ }
+ // barrier=gate points within a tile of the fence: a wicket on the nearest edge
+ foreach(var gp in gatePoints){var edge=NearestEdge(points,gp,1);if(edge is {} found&&line.Gates.All(g=>g.At!=found))line.Gates.Add(new(){At=found,Style=DefaultWicket.Id});}
+ lines.Add(line);
+ }
+ }
+ if(lines.Count==0)return;
+ var styles=p.FenceStyles.Concat(DefaultFenceStyles.Where(d=>p.FenceStyles.All(s=>s.Id!=d.Id)&&lines.Any(l=>l.Style==d.Id))).ToList();
+ var gates=p.GateStyles.Concat(lines.Any(l=>l.Gates.Any(g=>g.Style==DefaultWicket.Id))&&p.GateStyles.All(g=>g.Id!=DefaultWicket.Id)?[DefaultWicket]:[]).ToList();
+ foreach(var line in lines){try{FenceLines.Validate([line],styles,gates,258048);}catch(ArgumentException){droppedGates+=line.Gates.Count;line.Gates=[];}}
+ // with Preserve edits the previous fence layer was copied over: authored lines stay, source lines are refreshed
+ var kept=p.Fences.Where(old=>old.SourceId is null||lines.All(l=>l.SourceId!=old.SourceId)).ToList();
+ var total=kept.Concat(lines).ToList();if(total.Count>FenceLines.MaximumLines){skippedLines+=total.Count-FenceLines.MaximumLines;total=total.Take(FenceLines.MaximumLines).ToList();}
+ p.Fences=total;p.FenceStyles=styles;p.GateStyles=gates;
+ p.ImportSummary?.Notes.Add($"Fence lines from barrier=* paths: {lines.Count}" +(skippedLines>0?$", {skippedLines} outside the map or beyond limits":"")+(droppedGates>0?$", {droppedGates} gates dropped (unknown placement)":"")+". Diagonal fences follow cell edges as staircases.");
+ }
+ static (double X,double Y) PointAlong(List<(double X,double Y)> path,double distance){
+ for(var i=1;i0?Math.Clamp(distance/length,0,1):0;return(a.X+(b.X-a.X)*t,a.Y+(b.Y-a.Y)*t);}distance-=length;}
+ return path[0];}
+ /// The edge of a fence walk whose midpoint is nearest to a point, within a tolerance in tiles.
+ static int? NearestEdge(List points,(double X,double Y) at,double tolerance){int? best=null;var bestDistance=tolerance;var edge=0;
+ for(var i=1;i0)p.ImportSummary.Notes.Add($"Above-ground and basement levels were limited to 32 for {limitedLevels} source outlines.");if(approximateRoofs>0)p.ImportSummary.Notes.Add($"Unusual roof shapes were adapted for {approximateRoofs} outlines; original tags were preserved in the building properties.");
if(preserveEdits)p.ImportSummary.Notes.Add($"Manually edited buildings preserved: {p.Buildings.Count(b=>b.Modified)}. Adjacent source outlines may be skipped to preserve these edits.");
if(preserveEdits&&project.TerrainEdits.Length==0)p.ImportSummary.Notes.Add("Legacy manual terrain changes without a mask cannot be identified. Explicit surfaces, decoration, and buildings were preserved; unmarked terrain was refreshed from geographic data.");
+ ImportFences(p,transformed);
if(!preserveEdits)foreach(var b in p.Buildings)for(var y=b.Y/8;y<=(b.Y+b.Height)/8;y++)for(var x=b.X/8;x<=(b.X+b.Width)/8;x++)p.Zombies[y*(p.Width/8)+x]=90;
ImportQuality.Validate(p.ImportSummary);ProjectValidator.Validate(p);
var warnings=new List();if(skipped>0)warnings.Add($"Buildings skipped: {skipped}. "+string.Join("; ",skipReasons.Select(r=>$"{r.Key}: {r.Value}"))+". Outlines adapted to the grid; interiors generated.");if(omittedGeometry>0){var warning=$"Features with missing points or unclosed OSM relations skipped: {omittedGeometry}. Partial outlines were not used.";warnings.Add(warning);p.ImportSummary.Notes.Add(warning);}return new(p,imported,skipped,warnings){SourceBuildings=transformed.Count(f=>f.Polygon&&f.Tags.ContainsKey("building")),SourceFeatures=features.Count,SkipReasons=skipReasons};
diff --git a/tests/SimplePZMapmaker.Tests/FenceLineTests.cs b/tests/SimplePZMapmaker.Tests/FenceLineTests.cs
index 60a76d7..a55706a 100644
--- a/tests/SimplePZMapmaker.Tests/FenceLineTests.cs
+++ b/tests/SimplePZMapmaker.Tests/FenceLineTests.cs
@@ -59,6 +59,30 @@ public static class FenceLineTests
foreach(var cell in new[]{"100_100","101_100"}){var entry=zip.Entries.Single(e=>e.Name==$"{cell}.lotheader");using var r=new StreamReader(entry.Open(),Encoding.Latin1);Check(r.ReadToEnd().Contains("fencing_01_41"),$"Cell {cell} lost its part of the fence");}
world.Maps[1].Project.FenceStyles=[new(){Id="sheet",W="fencing_01_40",N="fencing_01_44"}];Reject(()=>WorldAssembler.Validate(world));
});
+ yield return("barrier lines import as staircase fence lines with styles, posts and gates",()=>{
+ var walk=GeographicImporter.FenceStaircase([(10.2,10.4),(30.1,10.3),(40.4,22.6)]);
+ Check(walk.First()==new FencePoint(10,10)&&walk.Last()==new FencePoint(40,23)&&walk.Zip(walk.Skip(1)).All(v=>v.First.X==v.Second.X^v.First.Y==v.Second.Y),"Fence walk is not a grid staircase");
+ Check(walk.Take(2).SequenceEqual([new FencePoint(10,10),new FencePoint(30,10)]),"A straight run was not kept as one segment");
+ foreach(var v in walk.Skip(1)){var t=(v.X-30.1)/(40.4-30.1);if(t is >0 and <1)Check(Math.Abs(v.Y-(10.3+t*12.3))<=1.5,"Staircase strays from the diagonal");}
+ var reference=new WorldGeoReference{Bounds=new(){West=32,East=32.01,South=54,North=54.01},MetresPerTile=1,OriginCellX=100,OriginCellY=100};var projection=new GeographicProjection(reference,100,100);
+ double[] At(double x,double y){var g=projection.Geographic(x,y);return[g.Longitude,g.Latitude];}
+ var json=JsonSerializer.Serialize(new{type="FeatureCollection",pzTransfer=1,features=new object[]{
+ new{type="Feature",id="osm-way-7",properties=new Dictionary{{"barrier","fence"},{"fence_type","metal_sheet"},{"pz:posts","4"},{"pz:gates","[{\"at\": 5, \"width\": 1, \"opens\": \"left\"}]"}},geometry=new{type="LineString",coordinates=new[]{At(20.2,40.1),At(60.3,40.2),At(70.1,52.4)}}},
+ new{type="Feature",id="osm-way-8",properties=new Dictionary{{"barrier","fence"},{"pz:fence","not-a-style"}},geometry=new{type="LineString",coordinates=new[]{At(20.1,80.2),At(20.3,100.1)}}},
+ new{type="Feature",id="osm-node-9",properties=new Dictionary{{"barrier","gate"}},geometry=new{type="Point",coordinates=At(40.5,40.1)}}}});
+ var p=GeographicImporter.Import(json,MapGenerator.Generate(new(){Kind="blank"}),reference.Bounds,projection:projection).Project;
+ Check(p.Fences.Count==2&&p.Version==12,"Barrier lines did not become fence lines");
+ var sheet=p.Fences.Single(f=>f.SourceId=="geo-osm-way-7");var planks=p.Fences.Single(f=>f.SourceId=="geo-osm-way-8");
+ Check(sheet.Style=="vanilla-sheet"&&planks.Style=="vanilla-planks"&&sheet.PostSpacing==4,"Fence looks were not taken from fence_type / defaults");
+ Check(sheet.Gates.Count==2&&sheet.Gates.Any(g=>g.At==5&&g.OpensTo=="left")&&sheet.Gates.Any(g=>g.At==20),"pz:gates (5 m along) or the barrier=gate node did not become gates on their edges");
+ Check(p.FenceStyles.Select(s=>s.Id).Order().SequenceEqual(["vanilla-planks","vanilla-sheet"])&&p.GateStyles.Single().Id=="vanilla-wicket","Only the used default styles should be added");
+ Check(Math.Abs(sheet.SourcePath[0].Longitude-At(20.2,40.1)[0])<1e-12,"Real source path was not kept");
+ var pieces=FenceLines.Compose(p.Fences,p.FenceStyles,p.GateStyles,p.Width,p.Height,blocked:FenceLines.Blocker(p)).Pieces;Check(pieces.Count(x=>x.Tile=="fixtures_doors_fences_01_13")==2,"Imported gates are not drawn");
+ // Preserve edits: authored fences stay, source fences are refreshed rather than duplicated
+ p.Fences.Add(new(){Id="authored",Style="vanilla-planks",Points=[new(100,100),new(110,100)]});
+ var again=GeographicImporter.Import(json,p,p.Bounds,preserveEdits:true,projection:projection).Project;
+ Check(again.Fences.Count==3&&again.Fences.Count(f=>f.SourceId=="geo-osm-way-7")==1&&again.Fences.Any(f=>f.Id=="authored"),"Reimport duplicated or lost fences");
+ });
}
static string Lotheader(byte[] archive,int x,int y){using var zip=new ZipArchive(new MemoryStream(archive));var entry=zip.Entries.Single(e=>e.Name==$"{x}_{y}.lotheader");using var r=new StreamReader(entry.Open(),Encoding.Latin1);return r.ReadToEnd();}
static void Check(bool ok,string message){if(!ok)throw new Exception(message);}