diff --git a/src/SimplePZMapmaker.Core/GeographicProjection.cs b/src/SimplePZMapmaker.Core/GeographicProjection.cs
index b4e453e..08cc1e7 100644
--- a/src/SimplePZMapmaker.Core/GeographicProjection.cs
+++ b/src/SimplePZMapmaker.Core/GeographicProjection.cs
@@ -6,7 +6,9 @@ public sealed class WorldGeoReference
public double MetresPerTile {get;set;}=1;
public int OriginCellX {get;set;}=100;
public int OriginCellY {get;set;}=100;
- public static void Validate(WorldGeoReference reference){if(reference is null)throw new ArgumentException("The world has no geographic reference.");ProjectValidator.ValidateBounds(reference.Bounds);if(!double.IsFinite(reference.MetresPerTile)||reference.MetresPerTile is <.5 or >20||reference.OriginCellX is <0 or >1000||reference.OriginCellY is <0 or >1000)throw new ArgumentException("Invalid world geographic reference: scale must be 0.5-20 meters per tile.");}
+ /// Rotation of the local metric plane (east, south) before it is laid on the tile grid; positive turns map content clockwise on screen. Lets a street grid that is not north-aligned become axis-aligned while source coordinates stay real lon/lat (docs/TRANSFER-CONTRACT.md, section 1).
+ public double RotationDegrees {get;set;}
+ public static void Validate(WorldGeoReference reference){if(reference is null)throw new ArgumentException("The world has no geographic reference.");ProjectValidator.ValidateBounds(reference.Bounds);if(!double.IsFinite(reference.MetresPerTile)||reference.MetresPerTile is <.5 or >20||reference.OriginCellX is <0 or >1000||reference.OriginCellY is <0 or >1000)throw new ArgumentException("Invalid world geographic reference: scale must be 0.5-20 meters per tile.");if(!double.IsFinite(reference.RotationDegrees)||reference.RotationDegrees is < -45 or >45)throw new ArgumentException("Invalid world geographic reference: rotation must be between -45 and 45 degrees.");}
}
public sealed class GeographicProjection
{
@@ -14,10 +16,20 @@ public sealed class GeographicProjection
public WorldGeoReference Reference {get;}
public int CellX {get;}
public int CellY {get;}
- private readonly double cos;
- public GeographicProjection(WorldGeoReference reference,int cellX,int cellY){WorldGeoReference.Validate(reference);Reference=reference;CellX=cellX;CellY=cellY;cos=Math.Cos((reference.Bounds.North+reference.Bounds.South)/2*Math.PI/180);}
- public (double X,double Y) Project((double X,double Y) coordinate)=>((coordinate.X-Reference.Bounds.West)*cos*MetresPerDegree/Reference.MetresPerTile-(CellX-Reference.OriginCellX)*256,(Reference.Bounds.North-coordinate.Y)*MetresPerDegree/Reference.MetresPerTile-(CellY-Reference.OriginCellY)*256);
- public (double Longitude,double Latitude) Geographic(double x,double y)=>(Reference.Bounds.West+(x+(CellX-Reference.OriginCellX)*256)*Reference.MetresPerTile/(cos*MetresPerDegree),Reference.Bounds.North-(y+(CellY-Reference.OriginCellY)*256)*Reference.MetresPerTile/MetresPerDegree);
- public GeoBounds SectionBounds(int width,int height){var top=Geographic(0,0);var bottom=Geographic(width,height);return new(){West=Math.Max(Reference.Bounds.West,top.Longitude),East=Math.Min(Reference.Bounds.East,bottom.Longitude),South=Math.Max(Reference.Bounds.South,bottom.Latitude),North=Math.Min(Reference.Bounds.North,top.Latitude)};}
- public static (int Width,int Height) RequiredTiles(WorldGeoReference reference){WorldGeoReference.Validate(reference);var projection=new GeographicProjection(reference,reference.OriginCellX,reference.OriginCellY);var corner=projection.Project((reference.Bounds.East,reference.Bounds.South));return((int)Math.Ceiling(corner.X),(int)Math.Ceiling(corner.Y));}
+ private readonly double cos,sin,rotationCos,centreX,centreY,offsetX,offsetY,tilesPerDegreeX,tilesPerDegreeY;
+ public GeographicProjection(WorldGeoReference reference,int cellX,int cellY){
+ WorldGeoReference.Validate(reference);Reference=reference;CellX=cellX;CellY=cellY;var b=reference.Bounds;
+ cos=Math.Cos((b.North+b.South)/2*Math.PI/180);var angle=reference.RotationDegrees*Math.PI/180;sin=Math.Sin(angle);rotationCos=Math.Cos(angle);
+ tilesPerDegreeX=cos*MetresPerDegree/reference.MetresPerTile;tilesPerDegreeY=MetresPerDegree/reference.MetresPerTile;centreX=(b.West+b.East)/2;centreY=(b.North+b.South)/2;
+ // The rotated bounds' minimum corner lands on the origin cell; with no rotation this is the bounds' north-west corner, as before.
+ var corners=new[]{(b.West,b.North),(b.East,b.North),(b.West,b.South),(b.East,b.South)}.Select(Rotated).ToArray();
+ offsetX=corners.Min(c=>c.X)+(cellX-reference.OriginCellX)*256;offsetY=corners.Min(c=>c.Y)+(cellY-reference.OriginCellY)*256;
+ }
+ private (double X,double Y) Rotated((double X,double Y) coordinate){var e=(coordinate.X-centreX)*tilesPerDegreeX;var n=(centreY-coordinate.Y)*tilesPerDegreeY;return(e*rotationCos-n*sin,e*sin+n*rotationCos);}
+ // Without rotation the original north-west-anchored formulas are kept verbatim so existing worlds project bit-identically.
+ public (double X,double Y) Project((double X,double Y) coordinate){if(Reference.RotationDegrees==0)return((coordinate.X-Reference.Bounds.West)*cos*MetresPerDegree/Reference.MetresPerTile-(CellX-Reference.OriginCellX)*256,(Reference.Bounds.North-coordinate.Y)*MetresPerDegree/Reference.MetresPerTile-(CellY-Reference.OriginCellY)*256);var r=Rotated(coordinate);return(r.X-offsetX,r.Y-offsetY);}
+ public (double Longitude,double Latitude) Geographic(double x,double y){if(Reference.RotationDegrees==0)return(Reference.Bounds.West+(x+(CellX-Reference.OriginCellX)*256)*Reference.MetresPerTile/(cos*MetresPerDegree),Reference.Bounds.North-(y+(CellY-Reference.OriginCellY)*256)*Reference.MetresPerTile/MetresPerDegree);var rx=x+offsetX;var ry=y+offsetY;var e=rx*rotationCos+ry*sin;var n=-rx*sin+ry*rotationCos;return(centreX+e/tilesPerDegreeX,centreY-n/tilesPerDegreeY);}
+ /// The source area a section must read: the bounding box of its corners, clipped to the reference bounds. With rotation it is larger than the section, so importers still clip by tile footprint.
+ public GeoBounds SectionBounds(int width,int height){var corners=new[]{Geographic(0,0),Geographic(width,0),Geographic(0,height),Geographic(width,height)};return new(){West=Math.Max(Reference.Bounds.West,corners.Min(c=>c.Longitude)),East=Math.Min(Reference.Bounds.East,corners.Max(c=>c.Longitude)),South=Math.Max(Reference.Bounds.South,corners.Min(c=>c.Latitude)),North=Math.Min(Reference.Bounds.North,corners.Max(c=>c.Latitude))};}
+ public static (int Width,int Height) RequiredTiles(WorldGeoReference reference){WorldGeoReference.Validate(reference);var projection=new GeographicProjection(reference,reference.OriginCellX,reference.OriginCellY);var b=reference.Bounds;var corners=new[]{(b.West,b.North),(b.East,b.North),(b.West,b.South),(b.East,b.South)}.Select(projection.Project).ToArray();return((int)Math.Ceiling(corners.Max(c=>c.X)),(int)Math.Ceiling(corners.Max(c=>c.Y)));}
}
diff --git a/src/SimplePZMapmaker.Core/WorldProject.cs b/src/SimplePZMapmaker.Core/WorldProject.cs
index 6b3776e..172bca6 100644
--- a/src/SimplePZMapmaker.Core/WorldProject.cs
+++ b/src/SimplePZMapmaker.Core/WorldProject.cs
@@ -6,7 +6,7 @@ public sealed class WorldProject
{
public string Format {get;set;}="SimplePZWorld";
private int version=1;
- public int Version {get=>LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;}
+ public int Version {get=>GeoReference?.RotationDegrees is not(null or 0)&&version<7?7:LinearFeatures?.Count>0&&version<6?6:version<5&&AuthoringFormats.WorldHasInfrastructure(this)?5:version<4&&AuthoringFormats.WorldHasExtendedLevels(this)?4:version<3&&AuthoringFormats.WorldHasNewAuthoring(this)?3:version==1&&(Buildings?.Count>0||Elevation is not null||Maps?.Any(m=>m?.Project?.Version==7)==true)?2:version;set=>version=value;}
public string Id {get;set;}=Guid.NewGuid().ToString("N");
public string Name {get;set;}="New world";
public List Maps {get;set;}=[];
@@ -24,7 +24,7 @@ public static partial class WorldAssembler
{
public sealed record Bounds(int X,int Y,int Width,int Height,int Cells);
public static Bounds Validate(WorldProject world){
- if(world is null||world.Format!="SimplePZWorld"||world.Version is not(1 or 2 or 3 or 4 or 5 or 6)||!SafeId(world.Id)||string.IsNullOrWhiteSpace(world.Name)||world.Name.Length>80||world.Name.Any(char.IsControl)||world.Maps is null||world.Maps.Count is <1 or >128)throw new ArgumentException("Invalid world project: 1-128 maps are required.");
+ if(world is null||world.Format!="SimplePZWorld"||world.Version is not(1 or 2 or 3 or 4 or 5 or 6 or 7)||!SafeId(world.Id)||string.IsNullOrWhiteSpace(world.Name)||world.Name.Length>80||world.Name.Any(char.IsControl)||world.Maps is null||world.Maps.Count is <1 or >128)throw new ArgumentException("Invalid world project: 1-128 maps are required.");
if(world.GeoReference is {} geographic)WorldGeoReference.Validate(geographic);
var ids=new HashSet();var cells=new HashSet<(int,int)>();var minX=int.MaxValue;var minY=int.MaxValue;var maxX=0;var maxY=0;
foreach(var member in world.Maps){if(member is null||!SafeId(member.Id)||!ids.Add(member.Id))throw new ArgumentException("Invalid world section ID.");var p=member.Project;ProjectValidator.Validate(p);
diff --git a/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs b/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs
index 413e7b2..4535d77 100644
--- a/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs
+++ b/src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs
@@ -4,7 +4,7 @@
using SimplePZMapmaker.Core.Export;
namespace SimplePZMapmaker.Web.Services;
-public sealed record OsmWorldImportRequest(GeoBounds Bounds,double MetresPerTile=1,int SectionSize=1024,int OriginCellX=100,int OriginCellY=100,int Seed=42,string Name="OpenStreetMap world",WorldProject? ExistingWorld=null,bool PreserveEdits=false);
+public sealed record OsmWorldImportRequest(GeoBounds Bounds,double MetresPerTile=1,int SectionSize=1024,int OriginCellX=100,int OriginCellY=100,int Seed=42,string Name="OpenStreetMap world",WorldProject? ExistingWorld=null,bool PreserveEdits=false,double RotationDegrees=0);
public sealed record OsmWorldPlan(WorldGeoReference GeoReference,int Columns,int Rows,int SectionSize,int Cells,int Width,int Height);
public sealed class OsmWorldImportService(OsmClient client,GameAssetCatalog assets,string directory,StaticModelCatalog? models=null):IDisposable
{
@@ -13,7 +13,7 @@ private sealed class Job(string id){public string Id=id;public string Status="qu
private readonly ConcurrentDictionary jobs=new();private readonly object admission=new();private bool disposed;
public static OsmWorldPlan Plan(OsmWorldImportRequest request){
if(request is null||string.IsNullOrWhiteSpace(request.Name)||request.Name.Length>80||request.Name.Any(char.IsControl)||request.SectionSize is <256 or >2048||request.SectionSize%256!=0)throw new ArgumentException("Specify a name and section dimensions of 256-2,048 tiles, in multiples of 256.");
- var reference=new WorldGeoReference{Bounds=request.Bounds,MetresPerTile=request.MetresPerTile,OriginCellX=request.OriginCellX,OriginCellY=request.OriginCellY};var size=GeographicProjection.RequiredTiles(reference);var columns=Math.Max(1,(int)Math.Ceiling(size.Width/(double)request.SectionSize));var rows=Math.Max(1,(int)Math.Ceiling(size.Height/(double)request.SectionSize));var cells=checked(columns*rows*(request.SectionSize/256)*(request.SectionSize/256));if(columns*rows>128||cells>8192)throw new ArgumentException("The area requires more than 128 maps or 8,192 native cells. Increase the tile scale or reduce the bounds.");
+ var reference=new WorldGeoReference{Bounds=request.Bounds,MetresPerTile=request.MetresPerTile,OriginCellX=request.OriginCellX,OriginCellY=request.OriginCellY,RotationDegrees=request.RotationDegrees};var size=GeographicProjection.RequiredTiles(reference);var columns=Math.Max(1,(int)Math.Ceiling(size.Width/(double)request.SectionSize));var rows=Math.Max(1,(int)Math.Ceiling(size.Height/(double)request.SectionSize));var cells=checked(columns*rows*(request.SectionSize/256)*(request.SectionSize/256));if(columns*rows>128||cells>8192)throw new ArgumentException("The area requires more than 128 maps or 8,192 native cells. Increase the tile scale or reduce the bounds.");
// A native world can be larger; a single browser import has a separate bounded memory budget.
if(cells>768)throw new ArgumentException("This import exceeds the world project's 128 MB budget. Reduce the area or increase the tile size; assemble larger worlds from separately saved sections.");
if(request.OriginCellX+(columns-1)*request.SectionSize/256>1000||request.OriginCellY+(rows-1)*request.SectionSize/256>1000)throw new ArgumentException("The import exceeds the editor's supported cell coordinates.");
diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js
index 8e87689..18baa83 100644
--- a/src/SimplePZMapmaker.Web/wwwroot/world-geography.js
+++ b/src/SimplePZMapmaker.Web/wwwroot/world-geography.js
@@ -5,9 +5,10 @@ import {usesExtendedZ} from './z-levels.js';
const $=id=>document.getElementById(id);
export const buildingTemplateVersion=b=>usesExtendedZ(b)?7:usesInteractiveAuthoring(b)?6:usesNativeModelBehavior(b)?5:4;
export function buildingTemplateFromDocument(data){if(data?.format!=='SimplePZBuilding'||![1,2,3,4,5,6,7].includes(data.version))throw Error('Template .pzbuilding.json expected.');const building=normalizeBuilding(structuredClone(data.building));if(usesExtendedZ(building)&&data.version<7)throw Error('Extended Z levels and open roofs require building template format 7.');if(usesInteractiveAuthoring(building)&&data.version<6)throw Error('Interactive objects and imported footprints require building template format 6.');validateDesign(building);return building;}
-export const worldAuthoringVersion=world=>world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version;
+export const worldAuthoringVersion=world=>world.geoReference?.rotationDegrees?Math.max(7,world.version):world.linearFeatures?.length?Math.max(6,world.version):world.maps?.some(m=>m.project?.version>=11)?Math.max(5,world.version):world.maps?.some(m=>m.project?.version>=10)||world.buildings?.some(usesExtendedZ)?Math.max(4,world.version):world.maps?.some(m=>m.project?.version>=9)||world.buildings?.some(usesInteractiveAuthoring)?Math.max(3,world.version):world.version;
export function elevationSample(grid,longitude,latitude){let x=longitude,y=latitude;if(grid.coordinateSystem==='EPSG:3857'){x=longitude*Math.PI/180*6378137;y=Math.log(Math.tan(Math.PI/4+latitude*Math.PI/360))*6378137;}const column=Math.floor((x-grid.west)/grid.cellSize+1e-9),row=grid.rows-1-Math.floor((y-grid.south)/grid.cellSize+1e-9);return column>=0&&row>=0&&column lon/lat, rotated reference included.
+export function worldGeographic(reference,x,y){const b=reference.bounds,cosine=Math.cos((b.north+b.south)/2*Math.PI/180);if(!reference.rotationDegrees)return {longitude:b.west+(x-reference.originCellX*256)*reference.metresPerTile/(cosine*111320),latitude:b.north-(y-reference.originCellY*256)*reference.metresPerTile/111320};const angle=(reference.rotationDegrees||0)*Math.PI/180,sin=Math.sin(angle),cos=Math.cos(angle),px=cosine*111320/reference.metresPerTile,py=111320/reference.metresPerTile,cx=(b.west+b.east)/2,cy=(b.north+b.south)/2,rotated=(lon,lat)=>{const e=(lon-cx)*px,n=(cy-lat)*py;return [e*cos-n*sin,e*sin+n*cos];},corners=[[b.west,b.north],[b.east,b.north],[b.west,b.south],[b.east,b.south]].map(c=>rotated(...c)),rx=x-reference.originCellX*256+Math.min(...corners.map(c=>c[0])),ry=y-reference.originCellY*256+Math.min(...corners.map(c=>c[1]));return {longitude:cx+(rx*cos+ry*sin)/px,latitude:cy-(-rx*sin+ry*cos)/py};}
export function validateWorldExtras(world){
validateWorldInfrastructure(world);
if(world.buildings!==undefined&&(!Array.isArray(world.buildings)||world.buildings.length>4000))throw Error('The shared world building layer is invalid.');const ids=new Set(),taken=new Set();const cellKey=(x,y)=>`${x}:${y}`;for(const member of world.maps||[])for(const b of member.project.buildings){ids.add(b.id);for(const i of occupiedIndices(b))taken.add(cellKey(member.project.worldX*256+b.x+i%(b.width+1),member.project.worldY*256+b.y+Math.floor(i/(b.width+1))));}for(const b of world.buildings||[]){validateDesign(b);if(typeof b.id!=='string'||!b.id||b.id.length>100||ids.has(b.id)||!Number.isInteger(b.x)||!Number.isInteger(b.y)||b.x<0||b.y<0||b.x>258000||b.y>258000)throw Error('The shared world building is invalid.');ids.add(b.id);for(const i of occupiedIndices(b)){const x=b.x+i%(b.width+1),y=b.y+Math.floor(i/(b.width+1)),member=world.maps?.find(m=>x>=m.project.worldX*256&&y>=m.project.worldY*256&&xs.x===lx&&s.y===ly)||(p.staticModels||[]).some(m=>m.x===lx&&m.y===ly)||(p.decorations||[]).some(d=>d.parts.some(part=>d.x+part.dx===lx&&d.y+part.dy===ly)))throw Error(`“${b.name}”: A shared building intersects an environment object or spawn point.`);}}
diff --git a/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js b/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js
index 8e67596..4f14a25 100644
--- a/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js
+++ b/src/SimplePZMapmaker.Web/wwwroot/world-workshop.js
@@ -19,7 +19,7 @@ export function placementIssue(world,p,x,y,skipId=null){
return null;
}
export function validateWorld(world,allowEmpty=false){
- if(!world||world.format!=='SimplePZWorld'||![1,2,3,4,5,6].includes(world.version)||!safeId(world.id))throw Error('The world format or identifier is corrupted.');
+ if(!world||world.format!=='SimplePZWorld'||![1,2,3,4,5,6,7].includes(world.version)||!safeId(world.id))throw Error('The world format or identifier is corrupted.');
if(typeof world.name!=='string'||!world.name.trim()||world.name.length>80||/[\x00-\x1f\x7f]/.test(world.name))throw Error('Enter the name of the world up to 80 characters.');
if(!Array.isArray(world.maps)||world.maps.length>(128)||(!allowEmpty&&!world.maps.length))throw Error('A world must contain 1–128 sections.');
const ids=new Set();for(const m of world.maps){if(!m||!safeId(m.id)||ids.has(m.id))throw Error('The world section identifier is invalid or duplicated.');ids.add(m.id);validateProject(m.project);const issue=placementIssue(world,m.project,m.project.worldX,m.project.worldY,m.id);if(issue)throw Error(issue);}
diff --git a/tests/SimplePZMapmaker.Tests/Program.cs b/tests/SimplePZMapmaker.Tests/Program.cs
index 541615e..5ecdab2 100644
--- a/tests/SimplePZMapmaker.Tests/Program.cs
+++ b/tests/SimplePZMapmaker.Tests/Program.cs
@@ -27,7 +27,7 @@
})
};
var failures=0;
-var all=tests.Concat(GeoTests.Cases()).Concat(ExportTests.Cases()).Concat(RegressionTests.Cases()).Concat(UpstreamTests.Cases()).Concat(PilnaTests.Cases()).Concat(RecoveryTests.Cases()).Concat(BuildingTests.Cases()).Concat(AssetTests.Cases()).Concat(VisualWorkshopTests.Cases()).Concat(HousingTemplateTests.Cases()).Concat(WorldTests.Cases()).Concat(AgentSessionTests.Cases()).Concat(ModAssetTests.Cases()).Concat(StaticModelTests.Cases()).Concat(CrossCellTests.Cases()).Concat(OsmWorldTests.Cases()).Concat(NeighborhoodTests.Cases()).Concat(RecoveryVersionTests.Cases()).Concat(WorldGeographyTests.Cases()).Concat(ArchitectureTests.Cases()).Concat(PlanningSessionTests.Cases()).Concat(IntegrationQualityTests.Cases()).Concat(TerrariumTests.Cases()).Concat(NeighborhoodParcelTests.Cases()).Concat(ImportQualityTests.Cases()).Concat(NativeViewportTests.Cases()).Concat(MapSceneStoreTests.Cases()).Concat(SimplePZMapmaker.Tests.PaletteTests.Cases()).Concat(SimplePZMapmaker.Tests.WorldMapBinaryTests.Cases()).Concat(LinearGeographyTests.Cases()).Concat(GameplayAuthoringTests.Cases()).Concat(NativeLightingGeometryTests.Cases()).Concat(AuthoringIntegrationTests.Cases()).Concat(ZLevelTests.Cases()).Concat(NativeVerticalRangeTests.Cases()).Concat(ZFormatTests.Cases()).Concat(SectionWorkspaceTests.Cases()).Concat(StreamingExportTests.Cases()).Concat(BuildingModuleTests.Cases()).Concat(InfrastructureTests.Cases()).Concat(ZonePlanningTests.Cases()).Concat(WorldInfrastructureTests.Cases()).Concat(ParametricBuildingTests.Cases()).Concat(StreetEnsembleTests.Cases()).Concat(ExportJobTests.Cases()).Concat(WalkabilityTests.Cases()).Concat(CheckedDesignTests.Cases()).Concat(InstallationDiscoveryTests.Cases()).Concat(RoofSurfaceTests.Cases()).Concat(WallOverlayTests.Cases()).Concat(SettlementGenerationTests.Cases()).Concat(GenerationStudioTests.Cases()).Concat(GenerationQualityTests.Cases()).Concat(GeographicEvidenceTests.Cases()).Concat(SiteTests.Cases()).Concat(SourceTransferTests.Cases()).ToArray();
+var all=tests.Concat(GeoTests.Cases()).Concat(RotatedProjectionTests.Cases()).Concat(ExportTests.Cases()).Concat(RegressionTests.Cases()).Concat(UpstreamTests.Cases()).Concat(PilnaTests.Cases()).Concat(RecoveryTests.Cases()).Concat(BuildingTests.Cases()).Concat(AssetTests.Cases()).Concat(VisualWorkshopTests.Cases()).Concat(HousingTemplateTests.Cases()).Concat(WorldTests.Cases()).Concat(AgentSessionTests.Cases()).Concat(ModAssetTests.Cases()).Concat(StaticModelTests.Cases()).Concat(CrossCellTests.Cases()).Concat(OsmWorldTests.Cases()).Concat(NeighborhoodTests.Cases()).Concat(RecoveryVersionTests.Cases()).Concat(WorldGeographyTests.Cases()).Concat(ArchitectureTests.Cases()).Concat(PlanningSessionTests.Cases()).Concat(IntegrationQualityTests.Cases()).Concat(TerrariumTests.Cases()).Concat(NeighborhoodParcelTests.Cases()).Concat(ImportQualityTests.Cases()).Concat(NativeViewportTests.Cases()).Concat(MapSceneStoreTests.Cases()).Concat(SimplePZMapmaker.Tests.PaletteTests.Cases()).Concat(SimplePZMapmaker.Tests.WorldMapBinaryTests.Cases()).Concat(LinearGeographyTests.Cases()).Concat(GameplayAuthoringTests.Cases()).Concat(NativeLightingGeometryTests.Cases()).Concat(AuthoringIntegrationTests.Cases()).Concat(ZLevelTests.Cases()).Concat(NativeVerticalRangeTests.Cases()).Concat(ZFormatTests.Cases()).Concat(SectionWorkspaceTests.Cases()).Concat(StreamingExportTests.Cases()).Concat(BuildingModuleTests.Cases()).Concat(InfrastructureTests.Cases()).Concat(ZonePlanningTests.Cases()).Concat(WorldInfrastructureTests.Cases()).Concat(ParametricBuildingTests.Cases()).Concat(StreetEnsembleTests.Cases()).Concat(ExportJobTests.Cases()).Concat(WalkabilityTests.Cases()).Concat(CheckedDesignTests.Cases()).Concat(InstallationDiscoveryTests.Cases()).Concat(RoofSurfaceTests.Cases()).Concat(WallOverlayTests.Cases()).Concat(SettlementGenerationTests.Cases()).Concat(GenerationStudioTests.Cases()).Concat(GenerationQualityTests.Cases()).Concat(GeographicEvidenceTests.Cases()).Concat(SiteTests.Cases()).Concat(SourceTransferTests.Cases()).ToArray();
if(Environment.GetEnvironmentVariable("PZMAP_TEST_FILTER") is {Length:>0} filter) all=all.Where(t=>t.Item1.Contains(filter,StringComparison.OrdinalIgnoreCase)).ToArray();
foreach(var (name,run) in all) { try {run(); Console.WriteLine($"PASS {name}");} catch(Exception e) {failures++;Console.WriteLine($"FAIL {name}: {e.Message}");} }
Console.WriteLine($"{all.Length-failures}/{all.Length} passed"); return failures==0 ? 0:1;
diff --git a/tests/SimplePZMapmaker.Tests/RotatedProjectionTests.cs b/tests/SimplePZMapmaker.Tests/RotatedProjectionTests.cs
new file mode 100644
index 0000000..b8ac6ba
--- /dev/null
+++ b/tests/SimplePZMapmaker.Tests/RotatedProjectionTests.cs
@@ -0,0 +1,48 @@
+using SimplePZMapmaker.Core;
+public static class RotatedProjectionTests
+{
+ public static IEnumerable<(string,Action)> Cases() {
+ yield return("rotated geographic reference round-trips, aligns a skewed street and leaves unrotated worlds unchanged",()=>{
+ var bounds=new GeoBounds{West=45.8817,East=45.9437,South=55.5354,North=55.5790};
+ var flat=new WorldGeoReference{Bounds=bounds,MetresPerTile=1,OriginCellX=200,OriginCellY=200};
+ var turned=new WorldGeoReference{Bounds=bounds,MetresPerTile=1,OriginCellX=200,OriginCellY=200,RotationDegrees=-11.22};
+ // Unrotated: the original north-west-anchored formula, bit for bit.
+ var p0=new GeographicProjection(flat,204,208);var cos=Math.Cos((bounds.North+bounds.South)/2*Math.PI/180);var q=(45.919,55.553);var a=p0.Project(q);
+ Check(a==((q.Item1-bounds.West)*cos*111320-(204-200)*256,(bounds.North-q.Item2)*111320-(208-200)*256),"Unrotated projection changed");
+ foreach(var (cx,cy) in new[]{(200,200),(204,208),(212,216)}){var p=new GeographicProjection(turned,cx,cy);
+ foreach(var (x,y) in new[]{(0.0,0.0),(511.5,17.25),(1023.0,1023.0)}){var g=p.Geographic(x,y);var back=p.Project((g.Longitude,g.Latitude));Check(Math.Abs(back.X-x)<1e-6&&Math.Abs(back.Y-y)<1e-6,"Rotated projection does not round-trip");}}
+ // A street at bearing 11.22 degrees east of north becomes a grid column after rotation by -11.22.
+ var origin=new GeographicProjection(turned,200,200);var lat0=55.55;var step=200.0/111320;var start=(45.91,lat0);
+ var end=(start.Item1+step*Math.Sin(11.22*Math.PI/180)/cos,lat0+step*Math.Cos(11.22*Math.PI/180));var s0=origin.Project(start);var s1=origin.Project(end);
+ Check(Math.Abs(s1.X-s0.X)<0.01&&Math.Abs(s0.Y-s1.Y-200)<0.01,$"Street was not aligned: {s0} -> {s1}");
+ // The whole rotated bounds land inside the world, starting at the origin cell.
+ var size=GeographicProjection.RequiredTiles(turned);var corners=new[]{(bounds.West,bounds.North),(bounds.East,bounds.North),(bounds.West,bounds.South),(bounds.East,bounds.South)}.Select(origin.Project).ToArray();
+ Check(Math.Abs(corners.Min(c=>c.X))<1e-6&&Math.Abs(corners.Min(c=>c.Y))<1e-6&&corners.Max(c=>c.X)<=size.Width&&corners.Max(c=>c.Y)<=size.Height,"Rotated bounds are not anchored to the origin cell");
+ // A section's source box covers all four of its corners.
+ var section=new GeographicProjection(turned,204,204);var box=section.SectionBounds(1024,1024);
+ foreach(var (x,y) in new[]{(0.0,0.0),(1024.0,0.0),(0.0,1024.0),(1024.0,1024.0)}){var g=section.Geographic(x,y);Check(g.Longitude>=box.West-1e-12&&g.Longitude<=box.East+1e-12&&g.Latitude>=box.South-1e-12&&g.Latitude<=box.North+1e-12,"Section source box misses a corner");}
+ Reject(()=>WorldGeoReference.Validate(new(){Bounds=bounds,RotationDegrees=46}));Reject(()=>WorldGeoReference.Validate(new(){Bounds=bounds,RotationDegrees=double.NaN}));
+ var world=new WorldProject{GeoReference=turned};Check(world.Version==7,"A rotated reference must raise the world format");Check(new WorldProject{GeoReference=flat}.Version==1,"An unrotated reference must not raise the world format");
+ });
+ yield return("rotated import places a skewed real house as a grid-aligned rectangle with its real outline kept",()=>{
+ var bounds=new GeoBounds{West=45.910,East=45.930,South=55.548,North=55.558};
+ Building Import(double rotation){
+ var reference=new WorldGeoReference{Bounds=bounds,MetresPerTile=1,OriginCellX=200,OriginCellY=200,RotationDegrees=rotation};
+ var size=GeographicProjection.RequiredTiles(reference);var cellX=200+size.Width/2/256;var cellY=200+size.Height/2/256;var projection=new GeographicProjection(reference,cellX,cellY);
+ // A 16 x 10 m house whose long side runs at bearing 90 - rotation degrees, centred off the tile grid lines near section tile (128, 128).
+ var cos=Math.Cos((bounds.North+bounds.South)/2*Math.PI/180);var angle=-rotation*Math.PI/180;var centre=projection.Geographic(128.4,128.3);
+ double[] At(double east,double south){var e=east*Math.Cos(angle)-south*Math.Sin(angle);var n=-(east*Math.Sin(angle)+south*Math.Cos(angle));return[centre.Longitude+e/(cos*111320),centre.Latitude+n/111320];}
+ var ring=new[]{At(-8,-5),At(8,-5),At(8,5),At(-8,5),At(-8,-5)};
+ var json=System.Text.Json.JsonSerializer.Serialize(new{type="FeatureCollection",features=new[]{new{type="Feature",id="osm-way-1",properties=new Dictionary{{"building","house"}},geometry=new{type="Polygon",coordinates=new[]{ring}}}}});
+ var blank=MapGenerator.Generate(new(){Kind="blank",Size=256});blank.WorldX=cellX;blank.WorldY=cellY;
+ var p=GeographicImporter.Import(json,blank,projection.SectionBounds(256,256),projection:projection).Project;
+ var source=p.SourceFeatures.Single(f=>f.SourceId=="geo-osm-way-1");Check(Math.Abs(source.SourceGeometry[0][0].Longitude-ring[0][0])<1e-12&&Math.Abs(source.SourceGeometry[0][0].Latitude-ring[0][1])<1e-12,"Real source coordinates were not kept");
+ return p.Buildings.Single();
+ }
+ var control=Import(0);var turned=Import(-11.22);
+ Check(turned.X==control.X&&turned.Y==control.Y&&turned.Width==control.Width&&turned.Height==control.Height&&turned.FootprintOrigin==control.FootprintOrigin,$"Skewed house under a rotated reference differs from a north-aligned one: {turned.X},{turned.Y} {turned.Width}x{turned.Height} {turned.FootprintOrigin} vs {control.X},{control.Y} {control.Width}x{control.Height} {control.FootprintOrigin}");
+ });
+ }
+ static void Check(bool ok,string message){if(!ok)throw new Exception(message);}
+ static void Reject(Action action){try{action();}catch(ArgumentException){return;}throw new Exception("Expected validation failure");}
+}
diff --git a/tests/world-geography.test.mjs b/tests/world-geography.test.mjs
index 07ac6cf..796f839 100644
--- a/tests/world-geography.test.mjs
+++ b/tests/world-geography.test.mjs
@@ -3,3 +3,7 @@ import assert from 'node:assert/strict';
import {elevationSample,worldGeographic,validateWorldExtras} from '../src/SimplePZMapmaker.Web/wwwroot/world-geography.js';
test('real elevation north-first orientation missing samples and projection match world origins',()=>{const g={coordinateSystem:'EPSG:4326',columns:2,rows:2,west:32,south:54.77,cellSize:.01,values:[120,130,100,null]};assert.equal(elevationSample(g,32.005,54.785),120);assert.equal(elevationSample(g,32.015,54.775),null);const ref={bounds:{west:32,north:54.79,south:54.77},originCellX:100,originCellY:100,metresPerTile:1};assert.deepEqual(worldGeographic(ref,25600,25600),{longitude:32,latitude:54.79});const boundary=worldGeographic(ref,25856,25600);assert.ok(boundary.longitude>32);assert.equal(boundary.latitude,54.79);validateWorldExtras({elevation:g,buildings:[]});assert.throws(()=>validateWorldExtras({elevation:{...g,values:[120]},buildings:[]}),/height/i);});
test('old world files without optional geographical layers stay accepted',()=>{assert.doesNotThrow(()=>validateWorldExtras({}));assert.throws(()=>validateWorldExtras({buildings:null}),/building/i);});
+test('rotated world reference matches the core projection',()=>{const ref={bounds:{west:45.8817,east:45.9437,south:55.5354,north:55.5790},originCellX:200,originCellY:200,metresPerTile:1,rotationDegrees:-11.22};
+ // Values printed by GeographicProjection.Geographic for the same reference (docs/TRANSFER-CONTRACT.md, section 1).
+ for(const [x,y,lon,lat] of [[51200,51200,45.88404732709845,55.585692652346324],[52500.5,53900.25,45.89596329642226,55.55962646485757],[54200,55300,45.91811455336323,55.54432211532234]]){const g=worldGeographic(ref,x,y);assert.ok(Math.abs(g.longitude-lon)<1e-11&&Math.abs(g.latitude-lat)<1e-11,`${x},${y}: ${g.longitude},${g.latitude}`);}
+ assert.deepEqual(worldGeographic({...ref,rotationDegrees:0},51200,51200),{longitude:45.8817,latitude:55.579});});