Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/SimplePZMapmaker.Core/GeographicProjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,30 @@ 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.");}
/// <summary>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).</summary>
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
{
public const double MetresPerDegree=111320;
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);}
/// <summary>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.</summary>
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)));}
}
4 changes: 2 additions & 2 deletions src/SimplePZMapmaker.Core/WorldProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorldMapMember> Maps {get;set;}=[];
Expand All @@ -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<string>();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);
Expand Down
4 changes: 2 additions & 2 deletions src/SimplePZMapmaker.Web/Services/OsmWorldImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -13,7 +13,7 @@ private sealed class Job(string id){public string Id=id;public string Status="qu
private readonly ConcurrentDictionary<string,Job> 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.");
Expand Down
5 changes: 3 additions & 2 deletions src/SimplePZMapmaker.Web/wwwroot/world-geography.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/SimplePZMapmaker.Web/wwwroot/world-workshop.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading