Editor tool for drawing walls along a path. Builds connected or single-piece walls with mitred or round joints, flat, battlement, ruined or capped tops, buttresses, base courses and single-face mode, and hands plain walls to the connection engine.
namespace AdvancedMappingTools;
public enum WallPlacement
{
[Title( "Single Pieces" )] Pieces,
[Title( "Connected Path" )] Connected
}
public enum WallMode
{
Solid,
[Title( "Single Face" )] SingleFace
}
public enum WallAlignment
{
Center,
Inside,
Outside
}
public enum WallFaceSide
{
Outside,
Inside
}
public enum WallTop
{
Flat,
Battlements,
Ruined,
[Title( "Coping Slab" )] Coping,
[Title( "Pitched Coping" )] Pitched,
[Title( "Rounded Cap" )] Rounded,
[Title( "Stepped Coping" )] Stepped
}
public enum WallJoint
{
Miter,
Round,
None
}
/// <summary>
/// Draw walls as one connected object with corners, curves and closed loops,
/// or as single pieces where every click finishes a wall and the next starts at its end.
/// </summary>
[ToolCategory( "Walls & Floors" )]
[Title( "Wall" ), Icon( "view_week" )]
public sealed partial class WallEditor( PrimitiveTool tool ) : PathEditor( tool )
{
const float CornerAngle = 25.0f;
readonly ConnectedWallGenerator _pieces = new();
Vector3? _lastDirection;
protected override int MinPoints => 2;
protected override bool CanClose => Placement == WallPlacement.Connected;
protected override bool PlacesPiecesInChain => Placement == WallPlacement.Pieces;
protected override bool PlacesObjects => UsesConnections;
/// <summary>
/// Plain single pieces connect through the connection engine. Pieces with a shaped top,
/// buttresses, a round post or only one face are made the old way, those are not parts yet.
/// </summary>
bool Plain => Mode == WallMode.Solid && Joints != WallJoint.Round
&& TopStyle == WallTop.Flat && ButtressSpacing <= 0.0f && SegmentLength <= 0.0f && GroundOffset == 0.0f;
bool UsesConnections => Plain && (Placement == WallPlacement.Pieces || ConnectedParts);
protected override bool SnapsToLines => true;
ConnectedWallSettings PieceSettings() => new()
{
Path = Curve(),
OpeningGrid = OpeningGrid,
Openings = OpeningGrid ? OpeningColumns : 0,
OpeningRows = OpeningRows,
OpeningRowSpacing = OpeningRowSpacing,
AutomaticOpeningKind = OpeningKind,
OpeningWidth = GridOpeningWidth,
OpeningHeight = GridOpeningHeight,
SillHeight = GridSill,
Plinth = BaseCourse > 0,
PlinthHeight = BaseCourse,
PlinthProjection = BaseOverhang,
Height = Height,
Thickness = Thickness,
SeparateWallMaterials = SeparateWallMaterials,
InteriorMaterial = PartTool.Pick( InteriorSurface?.ResourcePath, SurfaceMaterial ),
ExteriorMaterial = PartTool.Pick( ExteriorSurface?.ResourcePath, SurfaceMaterial ),
UseOpenings = UseWallOpenings,
Alignment = Alignment,
Join = AdvancedCorners ? CornerProfile : Joints == WallJoint.None ? WallJoinStyle.Overlap : WallJoinStyle.Miter,
Material = PartTool.Pick( null, SurfaceMaterial ),
WindowMaterial = PartTool.Pick( null, TrimMaterial ),
GlassMaterial = "materials/glass/glass_a.vmat",
Variation = 0.0f
};
protected override PolygonMesh BuildPreview() => PartService.Plan( _pieces, PieceSettings() ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
protected override void PlaceObjects()
{
var settings = PieceSettings();
settings.Connected = ConnectFinished;
PartTool.PlaceWall( _pieces, settings );
}
protected override IEnumerable<string> Hints => Placement == WallPlacement.Pieces
? ["Each click: finish a wall piece", "Esc: end the chain"]
: [.. base.Hints, "Click the green point: close loop"];
[Property, Group( "Wall" ), WideMode, Description( "Single Pieces makes one object per click, Connected Path makes one object for the whole line." )]
public WallPlacement Placement { get; set; } = WallPlacement.Connected;
[Property, Group( "Wall" ), Title( "Use Connections" ), WideMode, Description( "Builds plain walls through the connection engine, also a connected path. Fancy tops, buttresses and single faces stay on the old path." )]
public bool ConnectedParts { get; set; } = true;
[Property, Group( "Wall" ), Title( "Joints" ), WideMode, Description( "How single pieces meet at a corner." )]
public WallJoint Joints { get; set; } = WallJoint.Miter;
[Property, Group( "Wall" ), WideMode]
public WallMode Mode { get; set; } = WallMode.Solid;
[Property, Group( "Wall" ), WideMode, Description( "Where the wall sits relative to the path. Inside is left of the drawing direction, or inside a closed loop." )]
public WallAlignment Alignment { get; set; } = WallAlignment.Center;
[Property, ShowIf( nameof( Mode ), WallMode.SingleFace ), Group( "Wall" ), Title( "Face Side" ), WideMode, Description( "Which way a single face looks." )]
public WallFaceSide FaceSide { get; set; } = WallFaceSide.Outside;
[Property, Group( "Wall" ), Range( 1, 4096, slider: false ), WideMode]
public float Height { get; set; } = 128.0f;
[Property, Group( "Wall" ), Range( 0.25f, 512, slider: false ), WideMode]
public float Thickness { get; set; } = 8.0f;
[Property, Advanced, Group( "Wall" ), Title( "Ground Offset" ), Range( -1024, 1024, slider: false ), WideMode]
public float GroundOffset { get; set; } = 0.0f;
[Property, Advanced, Group( "Wall" ), WideMode]
public bool Top { get; set; } = true;
[Property, Advanced, Group( "Wall" ), WideMode]
public bool Bottom { get; set; } = true;
[Property, Advanced, Group( "Wall" ), Title( "End Caps" ), WideMode, Description( "Closes the two ends of an open wall." )]
public bool EndCaps { get; set; } = true;
[Property, Advanced, Group( "Wall" ), Title( "Segment Length" ), Range( 0, 1024, slider: false ), WideMode, Description( "Cuts long stretches into faces of this length. Zero keeps them whole." )]
public float SegmentLength { get; set; } = 0.0f;
[Property, Group( "Top Detail" ), Title( "Top Style" ), WideMode]
public WallTop TopStyle { get; set; } = WallTop.Flat;
[Property, ShowIf( nameof( TopStyle ), WallTop.Battlements ), Group( "Top Detail" ), Title( "Merlon Width" ), Range( 2, 256, slider: false ), WideMode]
public float MerlonWidth { get; set; } = 16.0f;
[Property, ShowIf( nameof( TopStyle ), WallTop.Battlements ), Group( "Top Detail" ), Title( "Merlon Height" ), Range( 1, 256, slider: false ), WideMode]
public float MerlonHeight { get; set; } = 16.0f;
[Property, ShowIf( nameof( TopStyle ), WallTop.Ruined ), Group( "Top Detail" ), Title( "Damage" ), Range( 0, 1 ), WideMode, Description( "How much of a ruined wall is broken away." )]
public float Damage { get; set; } = 0.4f;
[Property, ShowIf( nameof( TopStyle ), WallTop.Ruined ), Group( "Top Detail" ), Title( "Break Size" ), Range( 2, 256, slider: false ), WideMode]
public float BreakSize { get; set; } = 16.0f;
[Property, ShowIf( nameof( TopStyle ), WallTop.Ruined ), Group( "Top Detail" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
[Property, Group( "Top Detail" ), Title( "Cap Height" ), Range( 1, 128, slider: false ), WideMode, Description( "Height of a coping or cap on the wall top." )]
public float CapHeight { get; set; } = 6.0f;
[Property, Group( "Top Detail" ), Title( "Cap Overhang" ), Range( 0, 64, slider: false ), WideMode, Description( "How far the cap reaches past both faces of the wall." )]
public float CapOverhang { get; set; } = 2.0f;
bool HasCap => TopStyle is WallTop.Coping or WallTop.Pitched or WallTop.Rounded or WallTop.Stepped;
[Property, Group( "Buttresses" ), Title( "Buttress Spacing" ), Range( 0, 2048, slider: false ), WideMode, Description( "Supports on the outer side, this far apart. Zero turns them off." )]
public float ButtressSpacing { get; set; } = 0.0f;
[Property, HideIf( nameof( ButtressSpacing ), 0.0f ), Group( "Buttresses" ), Title( "Buttress Width" ), Range( 1, 256, slider: false ), WideMode]
public float ButtressWidth { get; set; } = 16.0f;
[Property, HideIf( nameof( ButtressSpacing ), 0.0f ), Group( "Buttresses" ), Title( "Buttress Depth" ), Range( 1, 256, slider: false ), WideMode]
public float ButtressDepth { get; set; } = 16.0f;
[Property, Group( "Base" ), Title( "Base Course" ), Range( 0, 256, slider: false ), WideMode, Description( "A projecting plinth at the foot of the wall. Zero turns it off." )]
public float BaseCourse { get; set; } = 0.0f;
[Property, HideIf( nameof( BaseCourse ), 0.0f ), Group( "Base" ), Title( "Base Overhang" ), Range( 0, 64, slider: false ), WideMode]
public float BaseOverhang { get; set; } = 4.0f;
protected override bool IsShapeValid => SamplePath().Count >= 2;
protected override void OnBuilt( MeshComponent component )
{
var path = SamplePath();
if ( path.Count >= 2 )
_lastDirection = (path[^1] - path[^2]).WithZ( 0.0f ).Normal;
}
public override void OnCancel()
{
_lastDirection = null;
base.OnCancel();
}
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
if ( SegmentLength > 0.0f )
path = PathFrames.Resample( path, Closed, MathF.Max( SegmentLength, 8.0f ) );
if ( TopStyle == WallTop.Ruined )
path = PathFrames.Resample( path, Closed, MathF.Max( BreakSize, 2.0f ) );
// SamplePath already contains the placement height; applying it to Frame again doubles it.
// Inside and outside follow the loop, whichever way round it was drawn.
var side = Closed && SignedArea( path ) < 0.0f ? -1.0f : 1.0f;
if ( Mode == WallMode.SingleFace )
{
BuildSingleFace( kit, path, side );
return;
}
var left = Alignment switch
{
WallAlignment.Inside => Thickness,
WallAlignment.Outside => 0.0f,
_ => Thickness * 0.5f
} * side;
var right = left - Thickness * side;
var outer = OffsetPath( path, Closed, left );
var inner = OffsetPath( path, Closed, right );
var bottom = Vector3.Up * GroundOffset;
var heights = TopHeights( path.Count );
var segments = Closed ? path.Count : path.Count - 1;
Vector3 Up( int index ) => Vector3.Up * (GroundOffset + heights[index]);
// Flip so faces always wind outward whichever side "outer" ended up on.
if ( side < 0.0f )
(outer, inner) = (inner, outer);
for ( var i = 0; i < segments; i++ )
{
var j = (i + 1) % path.Count;
kit.Material = SeparateWallMaterials ? (InteriorSurface ?? SurfaceMaterial) : SurfaceMaterial;
kit.Face( outer[j] + bottom, outer[i] + bottom, outer[i] + Up( i ), outer[j] + Up( j ) );
kit.Material = SeparateWallMaterials ? (ExteriorSurface ?? SurfaceMaterial) : SurfaceMaterial;
kit.Face( inner[i] + bottom, inner[j] + bottom, inner[j] + Up( j ), inner[i] + Up( i ) );
kit.Material = TrimMaterial;
if ( Top )
kit.Face( inner[i] + Up( i ), inner[j] + Up( j ), outer[j] + Up( j ), outer[i] + Up( i ) );
if ( Bottom )
kit.Face( inner[i] + bottom, outer[i] + bottom, outer[j] + bottom, inner[j] + bottom );
}
if ( !Closed && EndCaps )
{
kit.Material = TrimMaterial;
kit.Face( outer[0] + bottom, inner[0] + bottom, inner[0] + Up( 0 ), outer[0] + Up( 0 ) );
kit.Face( inner[^1] + bottom, outer[^1] + bottom, outer[^1] + Up( path.Count - 1 ), inner[^1] + Up( path.Count - 1 ) );
}
var merlonTop = GroundOffset + MathF.Max( Height, 1.0f ) + (TopStyle == WallTop.Battlements ? MerlonHeight : 0.0f);
if ( TopStyle == WallTop.Battlements )
BuildBattlements( kit, path, outer, inner );
if ( HasCap )
BuildCap( kit, path, left, right, heights );
if ( ButtressSpacing > 0.0f )
BuildButtresses( kit, path, side, right );
if ( BaseCourse > 0.0f )
{
var courseTop = GroundOffset + BaseCourse;
var over = MathF.Max( BaseOverhang, 0.0f );
var plinthOuter = OffsetPath( path, Closed, MathF.Max( left, right ) + over );
var plinthInner = OffsetPath( path, Closed, MathF.Min( left, right ) - over );
for ( var i = 0; i < segments; i++ )
{
var j = (i + 1) % path.Count;
kit.Material = SurfaceMaterial;
kit.Face( plinthOuter[j] + bottom, plinthOuter[i] + bottom, plinthOuter[i] + Vector3.Up * courseTop, plinthOuter[j] + Vector3.Up * courseTop );
kit.Face( plinthInner[i] + bottom, plinthInner[j] + bottom, plinthInner[j] + Vector3.Up * courseTop, plinthInner[i] + Vector3.Up * courseTop );
kit.Material = TrimMaterial;
kit.Face( plinthInner[i] + Vector3.Up * courseTop, plinthInner[j] + Vector3.Up * courseTop, plinthOuter[j] + Vector3.Up * courseTop, plinthOuter[i] + Vector3.Up * courseTop );
}
}
BuildJoint( kit, path, left, right, merlonTop );
}
/// <summary>
/// Closes the outer corner between this piece and the one before it. The square ends
/// of both pieces meet on the shared point, leaving a wedge on the outside of the turn.
/// </summary>
void BuildJoint( MeshKit kit, List<Vector3> path, float left, float right, float top )
{
if ( Placement != WallPlacement.Pieces || Joints == WallJoint.None ) return;
if ( _lastDirection is not Vector3 last || path.Count < 2 ) return;
var direction = (path[1] - path[0]).WithZ( 0.0f ).Normal;
var turn = last.x * direction.y - last.y * direction.x;
if ( MathF.Abs( turn ) < 0.01f ) return;
var point = path[0];
kit.Material = SurfaceMaterial;
if ( Joints == WallJoint.Round )
{
kit.Cylinder( point.WithZ( point.z ), Thickness * 0.5f, Thickness * 0.5f, GroundOffset, top, 16 );
return;
}
var distance = turn > 0.0f ? -right : left;
if ( distance <= 0.0f ) return;
var sign = turn > 0.0f ? -1.0f : 1.0f;
var before = new Vector3( -last.y, last.x, 0.0f ) * (distance * sign);
var after = new Vector3( -direction.y, direction.x, 0.0f ) * (distance * sign);
kit.Prism( [point, point + before, point + after], point.z + GroundOffset, point.z + top );
}
/// <summary>
/// A cap along the top of the wall, one closed body per segment. Its section runs across the wall
/// from the inner (0) to the outer (1) edge of the cap and upward, between the mitred corners, so
/// pieces meet at every corner without a gap.
/// </summary>
void BuildCap( MeshKit kit, List<Vector3> path, float left, float right, float[] heights )
{
var over = Math.Clamp( CapOverhang, 0.0f, 64.0f );
var height = Math.Clamp( CapHeight, 1.0f, 128.0f );
var a = OffsetPath( path, Closed, MathF.Min( left, right ) - over );
var b = OffsetPath( path, Closed, MathF.Max( left, right ) + over );
var segments = Closed ? path.Count : path.Count - 1;
var sections = new List<(float Across, float Up)[]>();
switch ( TopStyle )
{
case WallTop.Pitched:
sections.Add( [(0, 0), (1, 0), (1, height * .25f), (.5f, height), (0, height * .25f)] );
break;
case WallTop.Rounded:
{
var arc = new List<(float, float)> { (0, 0), (1, 0) };
for ( var k = 1; k < 8; k++ )
{
var t = MathF.PI * k / 8;
arc.Add( (.5f + .5f * MathF.Cos( t ), height * MathF.Sin( t )) );
}
sections.Add( [.. arc] );
break;
}
case WallTop.Stepped:
{
var inset = over > 0 ? Math.Clamp( over / (MathF.Abs( left - right ) + over * 2 ), 0.05f, .4f ) : .15f;
sections.Add( [(0, 0), (1, 0), (1, height * .4f), (0, height * .4f)] );
sections.Add( [(inset, height * .4f), (1 - inset, height * .4f), (1 - inset, height), (inset, height)] );
break;
}
default:
sections.Add( [(0, 0), (1, 0), (1, height), (0, height)] );
break;
}
kit.Material = TrimMaterial;
for ( var i = 0; i < segments; i++ )
{
var j = (i + 1) % path.Count;
Vector3 At( int index, (float Across, float Up) p ) =>
Vector3.Lerp( a[index], b[index], p.Across ) + Vector3.Up * (GroundOffset + heights[index] + p.Up);
foreach ( var section in sections )
{
var near = section.Select( p => At( i, p ) ).ToArray();
var far = section.Select( p => At( j, p ) ).ToArray();
ConvexBody( kit, near, far );
}
}
}
/// <summary>
/// A closed convex body between two matching outlines. Every face is turned away from the middle
/// of the body, so it comes out right whichever way the outlines run.
/// </summary>
static void ConvexBody( MeshKit kit, Vector3[] near, Vector3[] far )
{
var middle = (near.Aggregate( Vector3.Zero, ( s, p ) => s + p ) + far.Aggregate( Vector3.Zero, ( s, p ) => s + p )) / (near.Length + far.Length);
void Face( params Vector3[] ring )
{
var normal = Vector3.Zero;
for ( var k = 0; k < ring.Length; k++ )
normal += Vector3.Cross( ring[k], ring[(k + 1) % ring.Length] );
var center = ring.Aggregate( Vector3.Zero, ( s, p ) => s + p ) / ring.Length;
if ( normal.Dot( center - middle ) < 0 )
Array.Reverse( ring );
kit.Face( ring );
}
// Its own part: the end faces of two pieces meet exactly at a corner and must not be welded.
kit.BeginPart();
Face( [.. near] );
Face( [.. far] );
for ( var k = 0; k < near.Length; k++ )
{
var n = (k + 1) % near.Length;
Face( near[k], near[n], far[n], far[k] );
}
}
float[] TopHeights( int count )
{
var heights = new float[count];
var height = MathF.Max( Height, 1.0f );
var random = new Random( Seed );
for ( var i = 0; i < count; i++ )
{
heights[i] = TopStyle == WallTop.Ruined
? height * (1.0f - Damage * ((float)random.NextDouble() * 0.7f + (i % 2) * 0.3f))
: height;
}
return heights;
}
/// <summary>
/// Merlons sit directly on the edges of the wall, so they follow curves and two
/// merlons meeting at a mitred corner share its edge. Every stretch between corners
/// starts and ends on a merlon.
/// </summary>
void BuildBattlements( MeshKit kit, List<Vector3> path, Vector3[] outer, Vector3[] inner )
{
var count = path.Count;
var width = MathF.Max( MerlonWidth, 2.0f );
var low = path[0].z + GroundOffset + MathF.Max( Height, 1.0f );
var high = low + MerlonHeight;
kit.Material = TrimMaterial;
bool IsCorner( int index )
{
if ( !Closed && (index == 0 || index == count - 1) ) return true;
var a = (path[index] - path[(index + count - 1) % count]).WithZ( 0.0f ).Normal;
var b = (path[(index + 1) % count] - path[index]).WithZ( 0.0f ).Normal;
return a.Dot( b ) < MathF.Cos( CornerAngle.DegreeToRadian() );
}
var corners = Enumerable.Range( 0, count ).Where( IsCorner ).ToList();
if ( corners.Count == 0 ) corners.Add( 0 );
if ( Closed ) corners.Add( corners[0] + count );
for ( var c = 0; c < corners.Count - 1; c++ )
{
var stretch = Enumerable.Range( corners[c], corners[c + 1] - corners[c] + 1 ).Select( i => i % count ).ToList();
var lengths = new List<float> { 0.0f };
for ( var i = 0; i < stretch.Count - 1; i++ )
lengths.Add( lengths[^1] + path[stretch[i]].Distance( path[stretch[i + 1]] ) );
var total = lengths[^1];
var merlons = (int)MathF.Max( 1.0f, MathF.Floor( (total + width) / (width * 2.0f) ) );
var gap = merlons > 1 ? (total - width * merlons) / (merlons - 1) : 0.0f;
for ( var m = 0; m < merlons; m++ )
{
var from = merlons > 1 ? (width + gap) * m : MathF.Max( (total - width) * 0.5f, 0.0f );
var to = MathF.Min( from + width, total );
EmitMerlon( kit, stretch, lengths, outer, inner, from, to, low, high );
}
}
}
static void EmitMerlon( MeshKit kit, List<int> stretch, List<float> lengths, Vector3[] outer, Vector3[] inner, float from, float to, float low, float high )
{
(Vector3 Outer, Vector3 Inner) At( float distance )
{
for ( var i = 0; i < stretch.Count - 1; i++ )
{
if ( distance > lengths[i + 1] && i < stretch.Count - 2 ) continue;
var part = lengths[i + 1] - lengths[i];
var t = part > 0.0f ? (distance - lengths[i]) / part : 0.0f;
return (Vector3.Lerp( outer[stretch[i]], outer[stretch[i + 1]], t ), Vector3.Lerp( inner[stretch[i]], inner[stretch[i + 1]], t ));
}
return (outer[stretch[^1]], inner[stretch[^1]]);
}
var stops = new List<float> { from };
stops.AddRange( lengths.Where( x => x > from + 0.01f && x < to - 0.01f ) );
stops.Add( to );
for ( var i = 0; i < stops.Count - 1; i++ )
{
var a = At( stops[i] );
var b = At( stops[i + 1] );
kit.Prism( [a.Outer, b.Outer, b.Inner, a.Inner], low, high );
}
}
void BuildButtresses( MeshKit kit, List<Vector3> path, float side, float right )
{
var half = ButtressWidth * 0.5f;
var height = MathF.Max( Height, 1.0f ) * 0.85f;
kit.Material = TrimMaterial;
foreach ( var (position, direction) in WalkPath( path, Closed, MathF.Max( ButtressSpacing, 8.0f ) ) )
{
var along = direction.WithZ( 0.0f ).Normal;
var outward = new Vector3( along.y, -along.x, 0.0f ) * side;
var start = position - outward * (right * side);
var low = start - along * half;
var high = start + along * half;
var lowOut = low + outward * ButtressDepth;
var highOut = high + outward * ButtressDepth;
var up = Vector3.Up * height;
// Which way round depends on which side of the wall the outside is.
if ( side > 0.0f ) kit.Hex( high, low, lowOut, highOut, high + up, low + up, lowOut + up, highOut + up );
else kit.Hex( low, high, highOut, lowOut, low + up, high + up, highOut + up, lowOut + up );
}
}
void BuildSingleFace( MeshKit kit, List<Vector3> path, float side )
{
if ( SeparateWallMaterials )
kit.Material = (FaceSide == WallFaceSide.Inside ? InteriorSurface : ExteriorSurface) ?? SurfaceMaterial;
var flip = (FaceSide == WallFaceSide.Inside) == (side > 0.0f);
var segments = Closed ? path.Count : path.Count - 1;
var down = Vector3.Up * GroundOffset;
var heights = TopHeights( path.Count );
for ( var i = 0; i < segments; i++ )
{
var j = (i + 1) % path.Count;
var a = path[i];
var b = path[j];
var aUp = Vector3.Up * (GroundOffset + heights[i]);
var bUp = Vector3.Up * (GroundOffset + heights[j]);
if ( flip )
kit.Face( b + down, a + down, a + aUp, b + bUp );
else
kit.Face( a + down, b + down, b + bUp, a + aUp );
}
}
}