Editor-side procedural generator that sweeps a road/ramp mesh along a PathCurve. CurvedRampSettings holds rise, width, thickness, banking, section length, curbs, sidewalks, barriers, railings, supports and materials. CurvedRampGenerator builds the deck and side strips per section, plus railing posts and support columns, emitting PartPlans and mesh kits.
namespace AdvancedMappingTools;
public sealed class CurvedRampSettings : GeneratorSettings
{
/// <summary>
/// The line the ramp follows, set by the tool.
/// </summary>
public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 384.0f, 0.0f, 0.0f )] );
[Property, Group( "Ramp" ), Range( -4096, 4096, slider: false ), WideMode, Description( "Height added along the path, on top of the height of its points." )]
public float Rise { get; set; } = 64.0f;
[Property, Group( "Ramp" ), Title( "Ease Ends" ), WideMode, Description( "Starts and ends level, so the ramp meets a floor or road without a kink." )]
public bool EaseEnds { get; set; } = true;
[Property, Group( "Ramp" ), Range( 8, 2048, slider: false ), WideMode]
public float Width { get; set; } = 128.0f;
[Property, Group( "Ramp" ), Range( 0.5f, 128, slider: false ), WideMode]
public float Thickness { get; set; } = 8.0f;
[Property, Group( "Ramp" ), Title( "Banking" ), Range( -45, 45, slider: false ), WideMode, Description( "Degrees the ramp leans into its turns. Zero stays level across." )]
public float Banking { get; set; } = 0.0f;
[Property, Group( "Ramp" ), Title( "Section Length" ), Range( 16, 4096, slider: false ), WideMode, Description( "The ramp is cut into independent sections of about this length." )]
public float SectionLength { get; set; } = 256.0f;
[Property, Group( "Path" ), WideMode]
public PathLayout Layout { get; set; } = PathLayout.Drawn;
[Property, Group( "Path" ), Title( "Spiral Turns" ), Range( 0.1f, 32, slider: false ), WideMode]
public float Turns { get; set; } = 1.0f;
[Property, Group( "Path" ), Title( "Spiral Clockwise" ), WideMode]
public bool Clockwise { get; set; } = false;
[Property, Group( "Path" ), Title( "Path Detail" ), Range( 1, 256, slider: false ), WideMode, Description( "Longest straight piece along the path." )]
public float PathDetail { get; set; } = 16.0f;
[Property, Group( "Curbs" ), Title( "Left Curb" ), WideMode]
public bool LeftCurb { get; set; } = true;
[Property, Group( "Curbs" ), Title( "Right Curb" ), WideMode]
public bool RightCurb { get; set; } = true;
[Property, Group( "Curbs" ), Title( "Curb Width" ), Range( 1, 64, slider: false ), WideMode]
public float CurbWidth { get; set; } = 6.0f;
[Property, Group( "Curbs" ), Title( "Curb Height" ), Range( 0.5f, 32, slider: false ), WideMode]
public float CurbHeight { get; set; } = 6.0f;
[Property, Group( "Sidewalks" ), Title( "Left Sidewalk" ), WideMode]
public bool LeftSidewalk { get; set; } = true;
[Property, Group( "Sidewalks" ), Title( "Right Sidewalk" ), WideMode]
public bool RightSidewalk { get; set; } = true;
[Property, Group( "Sidewalks" ), Title( "Sidewalk Width" ), Range( 8, 512, slider: false ), WideMode]
public float SidewalkWidth { get; set; } = 48.0f;
[Property, Group( "Sidewalks" ), Title( "Sidewalk Height" ), Range( 0.5f, 32, slider: false ), WideMode]
public float SidewalkHeight { get; set; } = 5.0f;
[Property, Group( "Barriers" ), Title( "Left Barrier" ), WideMode, Description( "A solid low wall along the outer edge." )]
public bool LeftBarrier { get; set; } = false;
[Property, Group( "Barriers" ), Title( "Right Barrier" ), WideMode]
public bool RightBarrier { get; set; } = false;
[Property, Group( "Barriers" ), Title( "Barrier Width" ), Range( 1, 64, slider: false ), WideMode]
public float BarrierWidth { get; set; } = 8.0f;
[Property, Group( "Barriers" ), Title( "Barrier Height" ), Range( 4, 128, slider: false ), WideMode]
public float BarrierHeight { get; set; } = 32.0f;
[Property, Group( "Railings" ), Title( "Left Railing" ), WideMode]
public bool LeftRailing { get; set; } = false;
[Property, Group( "Railings" ), Title( "Right Railing" ), WideMode]
public bool RightRailing { get; set; } = false;
[Property, Group( "Railings" ), Title( "Railing Height" ), Range( 8, 128, slider: false ), WideMode]
public float RailingHeight { get; set; } = 36.0f;
[Property, Group( "Railings" ), Title( "Post Spacing" ), Range( 8, 1024, slider: false ), WideMode]
public float PostSpacing { get; set; } = 64.0f;
[Property, Group( "Supports" ), WideMode, Description( "Columns from under the ramp down to its lowest point." )]
public bool Supports { get; set; } = false;
[Property, ShowIf( nameof( Supports ), true ), Group( "Supports" ), Title( "Support Spacing" ), Range( 16, 2048, slider: false ), WideMode]
public float SupportSpacing { get; set; } = 192.0f;
[Property, ShowIf( nameof( Supports ), true ), Group( "Supports" ), Title( "Support Size" ), Range( 2, 128, slider: false ), WideMode]
public float SupportSize { get; set; } = 16.0f;
[Property, Group( "Materials" ), Title( "Deck" ), ResourceType( "vmat" ), WideMode]
public string DeckMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Curbs" ), ResourceType( "vmat" ), WideMode]
public string CurbMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Sidewalks" ), ResourceType( "vmat" ), WideMode]
public string SidewalkMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Details" ), ResourceType( "vmat" ), WideMode]
public string DetailMaterial { get; set; }
}
/// <summary>
/// A ramp along any 3D path. Deck, curbs, sidewalks and barriers are cuts through one shared
/// cross section, each swept along its own part of it. So every one of them follows the
/// same curve and the same climb, stays an independent part and meets its neighbour exactly.
/// </summary>
public sealed class CurvedRampGenerator : IPartGenerator
{
const string DeckCategory = "deck";
const string CurbCategory = "curb";
const string SidewalkCategory = "sidewalk";
const string BarrierCategory = "barrier";
const string RailingCategory = "railing";
const string SupportCategory = "support";
const float PostSize = 1.0f;
const float Clearance = 0.05f;
const float RailSize = 1.25f;
public string Id => "curvedramp";
public string Title => "Curved Ramp";
public Type SettingsType => typeof( CurvedRampSettings );
public IReadOnlyList<string> SeedNames => [DeckCategory, CurbCategory, SidewalkCategory, BarrierCategory, RailingCategory, SupportCategory];
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (CurvedRampSettings)settings;
var root = context.Group( "root", "Curved Ramp", "Building", DeckCategory );
if ( s.Path is null || s.Path.SegmentCount == 0 ) return root;
var path = s.Path;
if ( s.Banking != 0.0f )
{
path = System.Text.Json.JsonSerializer.Deserialize<PathCurve>( System.Text.Json.JsonSerializer.Serialize( path ) );
path.BankIntoTurns( s.Banking );
}
float Extent( bool hasCurb, bool hasWalk, bool hasBarrier ) =>
s.Width * 0.5f + (hasCurb ? s.CurbWidth : 0.0f) + (hasWalk ? s.SidewalkWidth : 0.0f) + (hasBarrier ? s.BarrierWidth : 0.0f);
var track = path.Track( s.PathDetail, FrameMode.Upright, 4.0f, path.Closed ? 0.0f : s.Rise, s.EaseEnds )
.Reach( Extent( s.LeftCurb, s.LeftSidewalk, s.LeftBarrier ), Extent( s.RightCurb, s.RightSidewalk, s.RightBarrier ) );
var deck = context.Material( s.DeckMaterial );
var curb = context.Material( s.CurbMaterial );
var walk = context.Material( s.SidewalkMaterial );
var detail = context.Material( s.DetailMaterial );
var bays = track.Bays( s.SectionLength );
var half = s.Width * 0.5f;
void Side( PartPlan bay, string id, string side, string label, float sign, bool hasCurb, bool hasWalk, bool hasBarrier, int from, int to )
{
// The side as a row of strips going outward from the edge of the deck, each with its top height.
var strips = new List<(string Name, string Title, string Kind, string Category, Material Material, float Width, float Top)>();
if ( hasCurb ) strips.Add( ("curb", "Curb", "Curb", CurbCategory, curb, s.CurbWidth, s.CurbHeight) );
if ( hasWalk ) strips.Add( ("sidewalk", "Sidewalk", "Sidewalk", SidewalkCategory, walk, s.SidewalkWidth, s.SidewalkHeight) );
if ( hasBarrier )
strips.Add( ("barrier", "Barrier", "Barrier", BarrierCategory, detail, s.BarrierWidth, (strips.Count > 0 ? strips[^1].Top : 0.0f) + s.BarrierHeight) );
var edge = half;
for ( var i = 0; i < strips.Count; i++ )
{
var strip = strips[i];
var a = edge;
var b = edge + strip.Width;
var inside = i > 0 ? strips[i - 1].Top : 0.0f;
var outside = i + 1 < strips.Count ? strips[i + 1].Top : float.MaxValue;
// Where a side touches a lower neighbour it gets a corner at that height. Both
// sides then share the same face, instead of two warped ones crossing each other.
var points = new List<Vector2> { new( a, -s.Thickness ), new( b, -s.Thickness ) };
if ( outside < strip.Top ) points.Add( new Vector2( b, outside ) );
points.Add( new Vector2( b, strip.Top ) );
points.Add( new Vector2( a, strip.Top ) );
if ( inside < strip.Top ) points.Add( new Vector2( a, inside ) );
IReadOnlyList<Vector2> section = sign > 0.0f ? points : [.. points.AsEnumerable().Reverse().Select( p => new Vector2( -p.x, p.y ) )];
bay.Add( context.Solid( $"{id}/{strip.Name}_{side}", $"{label} {strip.Title}", strip.Kind, strip.Category, strip.Material,
kit => SweepEngine.Build( kit, track, from, to, section ) ) );
edge = b;
}
}
for ( var b = 0; b < bays.Count; b++ )
{
var (from, to) = bays[b];
var id = $"section{b:00}";
var bay = root.Add( context.Group( id, $"Section {b + 1}", "Section", DeckCategory ) );
bay.Add( context.Solid( $"{id}/deck", "Deck", "RampDeck", DeckCategory, deck,
kit => SweepEngine.Build( kit, track, from, to, [new( -half, -s.Thickness ), new( half, -s.Thickness ), new( half, 0.0f ), new( -half, 0.0f )] ) ) );
Side( bay, id, "left", "Left", 1.0f, s.LeftCurb, s.LeftSidewalk, s.LeftBarrier, from, to );
Side( bay, id, "right", "Right", -1.0f, s.RightCurb, s.RightSidewalk, s.RightBarrier, from, to );
}
float Outer( bool hasCurb, bool hasWalk, bool hasBarrier ) =>
half + (hasCurb ? s.CurbWidth : 0.0f) + (hasWalk ? s.SidewalkWidth : 0.0f) + (hasBarrier ? s.BarrierWidth : 0.0f);
// A railing stands on whatever is outermost: the barrier top if there is one, else the walk.
float Surface( bool hasCurb, bool hasWalk, bool hasBarrier ) =>
(hasWalk ? s.SidewalkHeight : hasCurb ? s.CurbHeight : 0.0f) + (hasBarrier ? s.BarrierHeight : 0.0f);
if ( s.LeftRailing ) root.Add( Railing( context, s, track, detail, "railing_left", "Left Railing", Outer( s.LeftCurb, s.LeftSidewalk, s.LeftBarrier ) - 2.0f, Surface( s.LeftCurb, s.LeftSidewalk, s.LeftBarrier ) ) );
if ( s.RightRailing ) root.Add( Railing( context, s, track, detail, "railing_right", "Right Railing", -Outer( s.RightCurb, s.RightSidewalk, s.RightBarrier ) + 2.0f, Surface( s.RightCurb, s.RightSidewalk, s.RightBarrier ) ) );
if ( s.Supports ) root.Add( Supports( context, s, track, detail ) );
return root;
}
static PartPlan Railing( GenContext context, CurvedRampSettings s, PathTrack track, Material material, string id, string label, float left, float floor )
{
var group = context.Group( id, label, "Railing", RailingCategory );
var top = floor + s.RailingHeight;
var rail = track.Side( left, top );
group.Add( context.Solid( $"{id}/handrail", "Handrail", "Handrail", RailingCategory, material,
kit => SweepEngine.Build( kit, rail, 0, rail.Count - 1, [new( -RailSize, -RailSize ), new( RailSize, -RailSize ), new( RailSize, RailSize ), new( -RailSize, RailSize )] ) ) );
var count = Math.Max( (int)MathF.Round( track.Length / MathF.Max( s.PostSpacing, 8.0f ) ), 1 );
for ( var i = 0; i <= count; i++ )
{
var (station, along) = track.Locate( track.Length * i / count );
var rider = rail.PlaceBetween( station, along, 0.0f );
var next = Math.Min( station + 1, rail.Count - 1 );
var distance = rail.Frames[station].Distance + (rail.Frames[next].Distance - rail.Frames[station].Distance) * along;
// Both ends follow the slope: the foot rests on the walk, the head meets the underside of the rail.
// Walk and rail are swept square to the slope, so their faces lie 1/cos further from their line.
// Where the slope changes they kink a little to one side of the station; a hair of room covers that.
var stretch = Stretch( rail, distance );
group.Add( context.Solid( $"{id}/post{i:00}", $"Post {i + 1}", "RailingPost", RailingCategory, material,
kit => Upright( kit, rail, distance, rider, PostSize, -top + floor * stretch + Clearance, -RailSize * stretch - Clearance ) ) );
}
return group;
}
static PartPlan Supports( GenContext context, CurvedRampSettings s, PathTrack track, Material material )
{
var group = context.Group( "supports", "Supports", "Columns", SupportCategory );
var lowest = track.Frames.Min( x => x.Position.z );
var half = s.SupportSize * 0.5f;
var count = Math.Max( (int)MathF.Floor( track.Length / MathF.Max( s.SupportSpacing, 16.0f ) ), 1 );
for ( var i = 1; i < count || i == 1; i++ )
{
var point = track.PlaceAt( track.Length * i / MathF.Max( count, 2 ), 0.0f, -s.Thickness );
if ( point.z <= lowest + 1.0f ) continue;
var distance = track.Length * i / MathF.Max( count, 2 );
// The top follows the slope of the deck, so the column meets its underside all over.
var stretch = Stretch( track, distance );
group.Add( context.Solid( $"supports/column{i:00}", $"Column {i}", "Column", SupportCategory, material,
kit => Upright( kit, track, distance, point, half, lowest, -s.Thickness * stretch - Clearance, true ) ) );
}
return group;
}
/// <summary>
/// A square upright of half width <paramref name="half"/> around <paramref name="center"/>. Each
/// corner takes its heights from <paramref name="line"/> where that corner lies along it, so the
/// ends follow a sloping walk, rail or deck. <paramref name="low"/> and <paramref name="high"/>
/// are heights below the line; with <paramref name="flatFoot"/> the foot is level at <paramref name="low"/>.
/// </summary>
/// <summary>How much further a face swept square to the slope lies from its line, straight up: 1/cos.</summary>
static float Stretch( PathTrack line, float distance )
{
var forward = line.FrameAt( distance ).Forward;
var level = MathF.Sqrt( forward.x * forward.x + forward.y * forward.y );
return level > 0.2f ? 1.0f / level : 5.0f;
}
static void Upright( MeshKit kit, PathTrack line, float distance, Vector3 center, float half, float low, float high, bool flatFoot = false )
{
// Square to the path, so on a bend the upright stays inside the rail above it.
var forward = line.FrameAt( distance ).Forward.WithZ( 0 );
forward = forward.Length > 0.001f ? forward.Normal : Vector3.Forward;
var side = new Vector3( -forward.y, forward.x, 0 );
Vector3 Corner( float df, float ds, float lift, bool level )
{
var flat = center + forward * df + side * ds;
if ( level )
return flat.WithZ( lift );
var at = Math.Clamp( distance + df, 0.0f, line.Length );
return flat.WithZ( line.PlaceAt( at, 0.0f ).z + lift );
}
kit.Hex(
Corner( -half, -half, low, flatFoot ), Corner( half, -half, low, flatFoot ), Corner( half, half, low, flatFoot ), Corner( -half, half, low, flatFoot ),
Corner( -half, -half, high, false ), Corner( half, -half, high, false ), Corner( half, half, high, false ), Corner( -half, half, high, false ) );
}
}