Editor tooling for road authoring in the Advanced Mapping Tools package. Road Studio draws roads along a 3D line, optionally draping them on terrain with a ground trace and snapping ends onto existing roads to form junctions. Junction stamps a multi arm junction from a set of layout presets with per arm angle, length and lane overrides, and Parking Lot builds a rectangular lot from part settings.
namespace AdvancedMappingTools;
/// <summary>
/// Roads of independent parts along a drawn 3D line. A road that starts, ends or crosses on another
/// road gets a junction there by itself.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Road Studio" ), Icon( "add_road" )]
public sealed class RoadStudioEditor : PathEditor
{
protected override bool SnapsToLines => true;
const float SearchHeight = 2048.0f;
const float SearchDepth = 8192.0f;
public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "CurveSegments", "Variation", "Surface", "Trim" };
readonly RoadGenerator _generator = new();
bool _docked;
public RoadStudioEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;
public RoadSettings Settings { get; } = new();
public override object ExtraSettings => Settings;
[Property, Group( "Terrain" ), Title( "Follow Ground" ), WideMode, Description( "Lays the road on whatever is below its line, instead of straight between the clicked points." )]
public bool FollowGround { get; set; } = false;
[Property, ShowIf( nameof( FollowGround ), true ), Group( "Terrain" ), Title( "Ground Step" ), Range( 16, 1024, slider: false ), WideMode, Description( "How often the ground is looked up along the line." )]
public float GroundStep { get; set; } = 128.0f;
[Property, ShowIf( nameof( FollowGround ), true ), Group( "Terrain" ), Title( "Ground Offset" ), Range( -128, 128, slider: false ), WideMode, Description( "Height of the road over the ground." )]
public float GroundOffset { get; set; } = 0.0f;
protected override int MinPoints => 2;
protected override bool CanClose => true;
protected override bool PlacesObjects => true;
protected override bool AllowsHeight => true;
protected override bool IsShapeValid => Points.Count >= 2 && Points[0].Position.Distance( Points[1].Position ) > 1.0f;
protected override IEnumerable<string> Hints => [.. base.Hints, "Shift + drag a point: raise or lower it", "A point near a road snaps onto it and makes a junction", "Click the green point: close loop"];
public override bool ChangesOtherSettings( string property ) => property is nameof( RoadSettings.Type ) or nameof( RoadSettings.CustomDirectionWidths ) or nameof( RoadSettings.SeparateSidewalkWidths );
protected override bool KeepsHeight( Vector3 point ) => _docked;
protected override Vector3 Dock( Vector3 point )
{
var snapped = RoadLinker.Snap( SceneEditorSession.Active.Scene, point, DockRadius, out _docked );
return _docked ? snapped : base.Dock( point );
}
protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => RoadLinker.Removed( SceneEditorSession.Active.Scene, [Prepare()] );
protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing() =>
[.. RoadLinker.Network( SceneEditorSession.Active.Scene ).Except( ObjectsRemovedByPlacing() )];
protected override void BuildMesh( MeshKit kit ) { }
/// <summary>
/// The drawn line laid on the ground. Its ends stay where they were clicked, that is where a road may connect.
/// </summary>
PathCurve OnGround( PathCurve curve )
{
var scene = SceneEditorSession.Active.Scene;
var samples = curve.Sample( MathF.Max( GroundStep, 16.0f ), 12.0f );
var points = samples.Select( ( sample, i ) =>
{
if ( !curve.Closed && (i == 0 || i == samples.Count - 1) ) return sample.Position;
var hit = scene.Trace.Ray( sample.Position + Vector3.Up * SearchHeight, sample.Position + Vector3.Down * SearchDepth ).Run();
return hit.Hit ? sample.Position.WithZ( hit.EndPosition.z + GroundOffset ) : sample.Position;
} );
return PathCurve.FromPoints( points, PathInterpolation.CatmullRom, curve.Closed );
}
RoadSettings Prepare()
{
Settings.Path = FollowGround ? OnGround( Curve() ) : Curve();
return Settings;
}
protected override PolygonMesh BuildPreview()
{
// Cutting potholes takes too long for every mouse move, they come when the road is placed.
var holes = Settings.Potholes;
Settings.Potholes = false;
try
{
return PartService.Plan( _generator, Prepare() ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
}
finally
{
Settings.Potholes = holes;
}
}
protected override void PlaceObjects() => PartTool.PlaceRoads( _generator, [Prepare()] );
}
public enum JunctionLayout
{
[Title( "T Junction" )] T,
[Title( "Four Way" )] Cross,
[Title( "Y Junction" )] Y,
[Title( "Five Way" )] Five,
[Title( "Six Way" )] Six
}
/// <summary>
/// A finished junction with short roads leading away from it, to draw on from. It is
/// nothing but roads that meet in one point, so it stays a normal part of the network.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Junction" ), Icon( "fork_right" )]
public sealed class JunctionEditor( PrimitiveTool tool ) : StampEditor( tool )
{
const float ArmReach = 4.0f;
public override IReadOnlySet<string> UnusedOptions
{
get
{
var hidden = new HashSet<string>
{
"Surface",
"Trim",
"Variation",
"RandomTurn",
"Seed",
"Rise",
"EaseEnds",
"Banking"
};
for ( var i = Angles.Length + 1; i <= 6; i++ )
hidden.UnionWith( [$"Arm{i}Angle", $"Arm{i}Length", $"Arm{i}Lanes"] );
return hidden;
}
}
readonly RoadGenerator _generator = new();
public RoadSettings Settings { get; } = new();
public override object ExtraSettings => Settings;
[Property, Group( "Junction" ), WideMode]
public JunctionLayout Layout { get; set; } = JunctionLayout.Cross;
[Property, Group( "Junction" ), Title( "Arm Length" ), Range( 128, 4096, slider: false ), WideMode, Description( "How long the roads leading away are." )]
public float ArmLength { get; set; } = 768.0f;
[Property, Group( "Arm 1" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm1Angle { get; set; } = 0.0f;
[Property, Group( "Arm 1" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm1Length { get; set; } = 0.0f;
[Property, Group( "Arm 1" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm1Lanes { get; set; } = 0;
[Property, Group( "Arm 2" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm2Angle { get; set; } = 0.0f;
[Property, Group( "Arm 2" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm2Length { get; set; } = 0.0f;
[Property, Group( "Arm 2" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm2Lanes { get; set; } = 0;
[Property, Group( "Arm 3" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm3Angle { get; set; } = 0.0f;
[Property, Group( "Arm 3" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm3Length { get; set; } = 0.0f;
[Property, Group( "Arm 3" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm3Lanes { get; set; } = 0;
[Property, Group( "Arm 4" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm4Angle { get; set; } = 0.0f;
[Property, Group( "Arm 4" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm4Length { get; set; } = 0.0f;
[Property, Group( "Arm 4" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm4Lanes { get; set; } = 0;
[Property, Group( "Arm 5" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm5Angle { get; set; } = 0.0f;
[Property, Group( "Arm 5" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm5Length { get; set; } = 0.0f;
[Property, Group( "Arm 5" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm5Lanes { get; set; } = 0;
[Property, Group( "Arm 6" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
public float Arm6Angle { get; set; } = 0.0f;
[Property, Group( "Arm 6" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
public float Arm6Length { get; set; } = 0.0f;
[Property, Group( "Arm 6" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
public int Arm6Lanes { get; set; } = 0;
protected override bool PlacesObjects => true;
public override bool ChangesOtherSettings( string property ) => property is nameof( RoadSettings.Type ) or nameof( Layout );
protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => RoadLinker.Removed( SceneEditorSession.Active.Scene, Roads() );
protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing() =>
[.. RoadLinker.Network( SceneEditorSession.Active.Scene ).Except( ObjectsRemovedByPlacing() )];
protected override void Stamp( MeshKit kit, Transform frame, Random random ) { }
float[] Angles => Layout switch
{
JunctionLayout.T => [0.0f, 90.0f, 180.0f],
JunctionLayout.Y => [90.0f, 210.0f, 330.0f],
JunctionLayout.Five => [.. Enumerable.Range( 0, 5 ).Select( i => i * 72.0f )],
JunctionLayout.Six => [.. Enumerable.Range( 0, 6 ).Select( i => i * 60.0f )],
_ => [0.0f, 90.0f, 180.0f, 270.0f]
};
List<RoadSettings> Roads()
{
var roads = new List<RoadSettings>();
var angles = Angles;
float[] offsets = [Arm1Angle, Arm2Angle, Arm3Angle, Arm4Angle, Arm5Angle, Arm6Angle];
float[] lengths = [Arm1Length, Arm2Length, Arm3Length, Arm4Length, Arm5Length, Arm6Length];
int[] lanes = [Arm1Lanes, Arm2Lanes, Arm3Lanes, Arm4Lanes, Arm5Lanes, Arm6Lanes];
var templates = new List<RoadSettings>();
for ( var i = 0; i < angles.Length; i++ )
{
var road = (RoadSettings)GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( Settings ) );
if ( lanes[i] > 0 )
road.Lanes = Math.Clamp( lanes[i], 1, 12 );
templates.Add( road );
}
// Every arm must clear even the widest road crossing it.
var minimumReach = templates.Max( road => road.Half * ArmReach + road.CornerRadius );
var directions = angles.Select( ( angle, i ) => angle + Math.Clamp( offsets[i], -25.0f, 25.0f ) ).OrderBy( angle => angle ).ToArray();
var narrowest = Enumerable.Range( 0, directions.Length ).Min( i => (directions[(i + 1) % directions.Length] - directions[i] + 360.0f) % 360.0f );
// Acute approaches need longer arms so their inner corners can meet outside the centre.
var angleReach = 1.0f + 1.0f / MathF.Tan( narrowest * MathF.PI / 360.0f );
minimumReach = MathF.Max( minimumReach, templates.Max( road => road.Half * angleReach + road.CornerRadius ) );
foreach ( var point in Points )
{
for ( var i = 0; i < angles.Length; i++ )
{
var direction = Rotation.FromYaw( Yaw + angles[i] + Math.Clamp( offsets[i], -25.0f, 25.0f ) ).Forward;
var road = (RoadSettings)GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( templates[i] ) );
var requestedLength = lengths[i] > 0.0f ? Math.Clamp( lengths[i], 128.0f, 8192.0f ) : ArmLength;
var reach = MathF.Max( requestedLength, minimumReach );
road.Path = PathCurve.FromPoints( [point.Position, point.Position + direction * reach] );
road.Rise = 0.0f;
road.Stretches = [];
roads.Add( road );
}
}
return roads;
}
protected override PolygonMesh BuildPreview() => RoadLinker.Preview( _generator, Roads() );
protected override void PlaceObjects() => PartTool.PlaceRoads( _generator, Roads() );
}
/// <summary>
/// A parking lot on a rectangle, with an opening in its curb for a road.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Parking Lot" ), Icon( "local_parking" )]
public sealed class ParkingLotEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface", "Trim", "Variation" };
readonly ParkingLotGenerator _generator = new();
public ParkingLotSettings Settings { get; } = new();
protected override IPartGenerator Generator => _generator;
protected override GeneratorSettings Parts => Settings;
protected override void Prepare()
{
Settings.Width = RectLength;
Settings.Depth = RectDepth;
}
}