...
using System.Globalization;
using System.Text.Json;
namespace AdvancedMappingTools;
public enum ProfileStyle
{
Baseboard,
Cornice,
Beam,
Pipe,
Curb,
Gutter,
[Title( "Jersey Barrier" )] JerseyBarrier
}
/// <summary>
/// Run a moulding, beam, pipe or barrier along a path, for trim, cables and street furniture.
/// </summary>
[ToolCategory( "Props" )]
[Title( "Profile" ), Icon( "timeline" )]
public sealed class ProfileEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override bool SnapsToLines => true;
public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Trim" };
protected override int MinPoints => 2;
protected override bool CanClose => true;
protected override IEnumerable<string> Hints => [.. base.Hints, "Click the green point: close loop"];
[Property, Group( "Profile" ), WideMode]
public ProfileStyle Style { get; set; } = ProfileStyle.Baseboard;
[Property, Group( "Profile" ), Range( 0.25f, 512, slider: false ), WideMode]
public float Width { get; set; } = 4.0f;
[Property, Group( "Profile" ), Range( 0.25f, 512, slider: false ), WideMode]
public float Height { get; set; } = 8.0f;
[Property, Group( "Profile" ), Range( -4096, 4096, slider: false ), WideMode]
public float Elevation { get; set; } = 0.0f;
[Property, Group( "Profile" ), Title( "Pipe Sides" ), Range( 3, 32, slider: false ), WideMode]
public int PipeSides { get; set; } = 10;
protected override bool IsShapeValid => SamplePath().Count >= 2;
Vector2[] Section()
{
var w = Width;
var h = Height;
var z = Elevation;
Vector2[] shape = Style switch
{
ProfileStyle.Baseboard => [new( 0, 0 ), new( w, 0 ), new( w, h * 0.7f ), new( w * 0.5f, h ), new( 0, h )],
ProfileStyle.Cornice => [new( 0, 0 ), new( w * 0.3f, 0 ), new( w * 0.6f, h * 0.3f ), new( w, h * 0.6f ), new( w, h ), new( 0, h )],
ProfileStyle.Beam => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h ), new( -w / 2, h )],
ProfileStyle.Curb => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h * 0.8f ), new( w * 0.35f, h ), new( -w / 2, h )],
ProfileStyle.Gutter => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h ), new( w * 0.35f, h ), new( w * 0.35f, h * 0.2f ), new( -w * 0.35f, h * 0.2f ), new( -w * 0.35f, h ), new( -w / 2, h )],
ProfileStyle.JerseyBarrier => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h * 0.1f ), new( w * 0.3f, h * 0.3f ), new( w * 0.12f, h ), new( -w * 0.12f, h ), new( -w * 0.3f, h * 0.3f ), new( -w / 2, h * 0.1f )],
_ => [.. Enumerable.Range( 0, Math.Clamp( PipeSides, 3, 32 ) ).Select( i =>
{
var angle = MathF.PI * 2.0f * i / Math.Clamp( PipeSides, 3, 32 );
return new Vector2( MathF.Cos( angle ) * w * 0.5f, MathF.Sin( angle ) * w * 0.5f + w * 0.5f );
} )]
};
return [.. shape.Select( p => new Vector2( p.x, p.y + z ) )];
}
protected override void BuildMesh( MeshKit kit ) => SweepPath( kit, SamplePath(), Closed, Section() );
}
/// <summary>
/// A road or path with optional raised kerbs on both sides. It climbs and falls with its points.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Road" ), Icon( "add_road" )]
public sealed class RoadEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override int MinPoints => 2;
protected override bool CanClose => true;
protected override bool AllowsHeight => true;
protected override float HeightStep => Climb;
protected override IEnumerable<string> Hints => [.. base.Hints, "Shift + drag a point: raise or lower it", "Click the green point: close loop"];
[Property, Group( "Road" ), Title( "Climb Per Point" ), Range( -1024, 1024, slider: false ), WideMode, Description( "How much higher each new point lands than the one before it." )]
public float Climb { get; set; } = 0.0f;
[Property, Group( "Road" ), Range( 8, 2048, slider: false ), WideMode]
public float Width { get; set; } = 192.0f;
[Property, Group( "Road" ), Range( 0.25f, 128, slider: false ), WideMode]
public float Thickness { get; set; } = 4.0f;
[Property, Group( "Road" ), WideMode]
public bool Kerbs { get; set; } = true;
[Property, ShowIf( nameof( Kerbs ), true ), Group( "Road" ), Title( "Kerb Width" ), Range( 1, 256, slider: false ), WideMode]
public float KerbWidth { get; set; } = 12.0f;
[Property, ShowIf( nameof( Kerbs ), true ), Group( "Road" ), Title( "Kerb Height" ), Range( 0.5f, 64, slider: false ), WideMode]
public float KerbHeight { get; set; } = 6.0f;
[Property, Group( "Road" ), Title( "Center Line" ), WideMode, Description( "Dashes along the middle in the trim material." )]
public bool CenterLine { get; set; } = false;
protected override bool IsShapeValid => SamplePath().Count >= 2;
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
var half = Width * 0.5f;
var top = Thickness;
// Road and kerbs are one section. As separate bodies they would share a wall inside.
var surface = SurfaceMaterial;
var trim = TrimMaterial;
if ( Kerbs )
{
var outer = half + KerbWidth;
var high = top + KerbHeight;
// The underside has the same corners as the top: on a climbing turn both fold along the same lines.
SweepPath( kit, path, Closed,
[new( -outer, 0 ), new( -half, 0 ), new( half, 0 ), new( outer, 0 ), new( outer, high ), new( half, high ), new( half, top ), new( -half, top ), new( -half, high ), new( -outer, high )],
[trim, trim, trim, trim, trim, trim, surface, trim, trim, trim] );
}
else
{
SweepPath( kit, path, Closed, [new( -half, 0 ), new( half, 0 ), new( half, top ), new( -half, top )] );
}
kit.Material = TrimMaterial;
if ( !CenterLine ) return;
foreach ( var (position, direction) in WalkPath( path, Closed, 96.0f ) )
kit.Beam( position + Vector3.Up * (top + 0.05f), position + direction * 48.0f + Vector3.Up * (top + 0.05f), 4.0f, 0.1f );
}
}
/// <summary>
/// A dug in trench: two revetted walls and a floor. It sits below the drawn line.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Trench" ), Icon( "horizontal_rule" )]
public sealed class TrenchEditor( PrimitiveTool tool ) : PathEditor( tool )
{
const float MinimumMeet = 0.3f;
const float MinimumMeetLength = 8.0f;
protected override bool SnapsToLines => true;
protected override bool CanClose => true;
protected override int MinPoints => 2;
[Property, Group( "Trench" ), Range( 16, 512, slider: false ), WideMode]
public float Width { get; set; } = 64.0f;
[Property, Group( "Trench" ), Range( 16, 512, slider: false ), WideMode]
public float Depth { get; set; } = 80.0f;
[Property, Group( "Trench" ), Title( "Wall Thickness" ), Range( 1, 64, slider: false ), WideMode]
public float WallThickness { get; set; } = 6.0f;
[Property, Group( "Trench" ), Title( "Post Spacing" ), Range( 8, 512, slider: false ), WideMode]
public float PostSpacing { get; set; } = 64.0f;
/// <summary>
/// Where other trenches come in from the side, as "x y z half" separated by ";": a spot on the
/// wall in the coordinates of the points, and half the width of the gap there.
/// </summary>
[Property]
public string Branches { get; set; } = "";
public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { nameof( Branches ) };
readonly List<(GameObject Owner, Vector3 Spot, float Half)> _meets = [];
protected override bool IsShapeValid => SamplePath().Count >= 2;
float Half => Width * 0.5f;
float Outer => Half + WallThickness;
IEnumerable<(Vector3 Spot, float Half)> ReadBranches() =>
(Branches ?? "").Split( ';', StringSplitOptions.RemoveEmptyEntries ).Select( x => x.Split( ' ' ).Select( y => float.Parse( y, CultureInfo.InvariantCulture ) ).ToArray() ).Select( x => (new Vector3( x[0], x[1], x[2] ), x[3]) );
public void AddBranch( Vector3 spot, float half ) =>
Branches = (Branches ?? "") + string.Create( CultureInfo.InvariantCulture, $"{spot.x} {spot.y} {spot.z} {half};" );
/// <summary>
/// An end on the side of a finished trench stops at the back of its wall, and the wall gets a
/// gap as wide as this trench. Floor meets floor, the corners stay open to walk through.
/// </summary>
protected override void JoinSides( Scene scene )
{
_meets.Clear();
if ( Closed || Points.Count < 2 ) return;
foreach ( var (index, next) in new[] { (0, 1), (Points.Count - 1, Points.Count - 2) } )
{
var end = Points[index].Position;
if ( LineSnap.Side( scene, end, GetType().Name ) is not { } hit ) continue;
var toward = (Points[next].Position - end).WithZ( 0.0f ).Normal;
var flat = hit.Along.WithZ( 0.0f ).Normal;
var across = (toward - flat * toward.Dot( flat )).Normal;
var square = toward.Dot( across );
if ( square < MinimumMeet ) continue;
var other = hit.Record.Settings;
var half = Read( other, nameof( Width ), Width ) * 0.5f;
var wall = Read( other, nameof( WallThickness ), WallThickness );
var pull = (half + wall) / square;
if ( end.Distance( Points[next].Position ) <= pull + MinimumMeetLength ) continue;
MovePoint( index, hit.On + toward * pull );
_meets.Add( (hit.Owner, hit.On + across * (half + wall * 0.5f), Half / square) );
}
}
static float Read( Dictionary<string, string> settings, string name, float fallback ) =>
settings.TryGetValue( name, out var text ) && float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ? value : fallback;
protected override void TakeOver( GameObject go, BuilderRecord record )
{
if ( record.Settings.TryGetValue( nameof( Branches ), out var text ) && !string.IsNullOrEmpty( text ) )
Branches = (Branches ?? "") + JsonSerializer.Deserialize<string>( text );
}
protected override void OnBuilt( MeshComponent component )
{
foreach ( var (owner, spot, half) in _meets )
{
var record = BuilderStore.LoadRecord( owner.Id );
var origin = new Vector3( record.Origin[0], record.Origin[1], record.Origin[2] );
var local = owner.WorldTransform.PointToLocal( spot ) + origin;
Rebuild( owner, x => ((TrenchEditor)x).AddBranch( local, half ) );
}
_meets.Clear();
}
static float Project( IReadOnlyList<Vector3> path, bool closed, Vector3 point, out Vector3 tangent, out Vector3 on )
{
var best = float.MaxValue;
var walked = 0.0f;
var result = 0.0f;
var steps = closed ? path.Count : path.Count - 1;
tangent = Vector3.Forward;
on = point;
for ( var i = 0; i < steps; i++ )
{
var a = path[i];
var along = path[(i + 1) % path.Count] - a;
var length = along.Length;
var t = length > 0.0f ? Math.Clamp( (point - a).WithZ( 0.0f ).Dot( along.WithZ( 0.0f ) ) / along.WithZ( 0.0f ).LengthSquared, 0.0f, 1.0f ) : 0.0f;
var spot = a + along * t;
var distance = spot.WithZ( 0.0f ).Distance( point.WithZ( 0.0f ) );
if ( distance < best )
{
best = distance;
result = walked + length * t;
tangent = along.Normal;
on = spot;
}
walked += length;
}
return result;
}
static List<Vector3> Slice( IReadOnlyList<Vector3> path, bool closed, float from, float to )
{
var result = new List<Vector3>();
var count = closed ? path.Count + 1 : path.Count;
var walked = 0.0f;
// Twice round for a closed line, a piece may run over its seam.
for ( var round = 0; round < (closed ? 2 : 1); round++ )
{
for ( var i = 0; i + 1 < count; i++ )
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var length = a.Distance( b );
var stop = walked + length;
if ( stop > from && walked < to && length > 0.0f )
{
if ( result.Count == 0 ) result.Add( Vector3.Lerp( a, b, (MathF.Max( from, walked ) - walked) / length ) );
result.Add( Vector3.Lerp( a, b, (MathF.Min( to, stop ) - walked) / length ) );
}
walked = stop;
}
}
return result;
}
/// <summary>
/// One side wall, in pieces if trenches come in on that side.
/// </summary>
void Wall( MeshKit kit, List<Vector3> path, float sign, IReadOnlyList<Vector2> section, List<(Vector3 Spot, float Half)> gaps )
{
var cuts = gaps
.Select( x => (Along: Project( path, Closed, x.Spot, out var tangent, out var on ), x.Half, Side: new Vector3( -tangent.y, tangent.x, 0.0f ).Dot( x.Spot - on ) * sign) )
.Where( x => x.Side > 0.0f ).OrderBy( x => x.Along ).ToList();
if ( cuts.Count == 0 )
{
SweepPath( kit, path, Closed, section );
return;
}
var total = 0.0f;
var steps = Closed ? path.Count : path.Count - 1;
for ( var i = 0; i < steps; i++ ) total += path[i].Distance( path[(i + 1) % path.Count] );
var pieces = new List<(float From, float To)>();
if ( !Closed ) pieces.Add( (0.0f, cuts[0].Along - cuts[0].Half) );
for ( var i = 0; i + 1 < cuts.Count; i++ )
pieces.Add( (cuts[i].Along + cuts[i].Half, cuts[i + 1].Along - cuts[i + 1].Half) );
pieces.Add( Closed ? (cuts[^1].Along + cuts[^1].Half, cuts[0].Along - cuts[0].Half + total) : (cuts[^1].Along + cuts[^1].Half, total) );
foreach ( var (from, to) in pieces )
{
if ( to - from < 1.0f ) continue;
var piece = Slice( path, Closed, from, to );
if ( piece.Count >= 2 ) SweepPath( kit, piece, false, section );
}
}
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
var half = Half;
var low = -Depth;
var wall = Outer;
var gaps = ReadBranches().ToList();
// Floor and walls as three simple rectangles. The compound U section swept into
// itself and put the floor on the wrong side, which showed up as huge plates.
SweepPath( kit, path, Closed, [new( -wall, low - 4 ), new( wall, low - 4 ), new( wall, low ), new( -wall, low )] );
Wall( kit, path, 1.0f, [new( half, low ), new( wall, low ), new( wall, 0 ), new( half, 0 )], gaps );
Wall( kit, path, -1.0f, [new( -wall, low ), new( -half, low ), new( -half, 0 ), new( -wall, 0 )], gaps );
kit.Material = TrimMaterial;
var rib = MathF.Max( WallThickness * 0.4f, 1.0f );
foreach ( var (position, direction) in WalkPath( path, Closed, MathF.Max( PostSpacing, 8.0f ) ) )
{
// A pilaster in the middle of each wall, flush with the rim, instead of a fin
// standing in the trench.
var left = new Vector3( -direction.y, direction.x, 0.0f ).Normal * (half + WallThickness * 0.5f);
// Near a corner the spot beside the line is not wall any more, a post there would
// stand alone in the open. Where a trench comes in there is no wall at all.
foreach ( var post in new[] { position + left, position - left } )
{
if ( !InWall( path, post, half, wall ) || gaps.Any( x => x.Spot.WithZ( 0.0f ).Distance( post.WithZ( 0.0f ) ) < x.Half + rib ) ) continue;
kit.Box( post + new Vector3( -rib, -rib, low ), post + new Vector3( rib, rib, 1.0f ) );
}
}
}
bool InWall( IReadOnlyList<Vector3> path, Vector3 point, float inner, float outer )
{
var nearest = float.MaxValue;
var steps = Closed ? path.Count : path.Count - 1;
var flat = point.WithZ( 0.0f );
for ( var i = 0; i < steps; i++ )
{
var a = path[i].WithZ( 0.0f );
var along = path[(i + 1) % path.Count].WithZ( 0.0f ) - a;
var t = along.LengthSquared > 0.0f ? Math.Clamp( (flat - a).Dot( along ) / along.LengthSquared, 0.0f, 1.0f ) : 0.0f;
nearest = MathF.Min( nearest, (a + along * t).Distance( flat ) );
}
return nearest >= inner && nearest <= outer;
}
}
/// <summary>
/// A balustrade or handrail along a path.
/// </summary>
public enum RailingInfill
{
[Title( "None" )] None,
[Title( "Vertical Bars" )] Bars,
[Title( "Glass Band" )] Glass,
[Title( "Horizontal Wires" )] Wires
}
[ToolCategory( "Walls & Floors" )]
[Title( "Railing" ), Icon( "drag_handle" )]
public sealed class RailingEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override bool SnapsToLines => true;
protected override int MinPoints => 2;
protected override bool CanClose => true;
[Property, Group( "Railing" ), Range( 8, 256, slider: false ), WideMode]
public float Height { get; set; } = 40.0f;
[Property, Group( "Railing" ), Title( "Post Spacing" ), Range( 4, 512, slider: false ), WideMode]
public float PostSpacing { get; set; } = 8.0f;
[Property, Group( "Railing" ), Title( "Post Size" ), Range( 0.25f, 32, slider: false ), WideMode]
public float PostSize { get; set; } = 2.0f;
[Property, Group( "Railing" ), Title( "Rail Width" ), Range( 0.5f, 64, slider: false ), WideMode]
public float RailWidth { get; set; } = 4.0f;
[Property, Group( "Railing" ), Title( "Lower Rail" ), WideMode]
public bool LowerRail { get; set; } = true;
[Property, Group( "Railing" ), Title( "Infill" ), WideMode, Description( "What fills the space between the rails." )]
public RailingInfill Infill { get; set; } = RailingInfill.None;
[Property, HideIf( nameof( Infill ), RailingInfill.None ), Group( "Railing" ), Title( "Infill Spacing" ), Range( 0.5f, 64, slider: false ), WideMode]
public float InfillSpacing { get; set; } = 6.0f;
[Property, HideIf( nameof( Infill ), RailingInfill.None ), Group( "Railing" ), Title( "Infill Size" ), Range( 0.2f, 8, slider: false ), WideMode]
public float InfillSize { get; set; } = 1.0f;
[Property, Group( "Railing" ), Title( "Post Caps" ), WideMode, Description( "A small cap on top of every post." )]
public bool PostCaps { get; set; } = false;
protected override bool IsShapeValid => SamplePath().Count >= 2;
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
var half = RailWidth * 0.5f;
foreach ( var (position, _) in WalkPath( path, Closed, MathF.Max( PostSpacing, 4.0f ) ) )
{
var size = PostSize * 0.5f;
var top = Height - half;
kit.Box( position + new Vector3( -size, -size, 0.0f ), position + new Vector3( size, size, top ) );
if ( !PostCaps ) continue;
var cap = size * 1.4f;
kit.Box( position + new Vector3( -cap, -cap, top ), position + new Vector3( cap, cap, top + 1.4f ) );
}
kit.Material = TrimMaterial;
SweepPath( kit, path, Closed, [new( -half, Height - half ), new( half, Height - half ), new( half, Height ), new( -half, Height )] );
if ( LowerRail )
SweepPath( kit, path, Closed, [new( -half, 4 ), new( half, 4 ), new( half, 4 + half ), new( -half, 4 + half )] );
if ( Infill == RailingInfill.Bars )
{
var bar = MathF.Max( InfillSize, 0.2f ) * 0.5f;
var bottom = LowerRail ? 4.0f + half : 2.0f;
var top = Height - half;
foreach ( var (position, _) in WalkPath( path, Closed, MathF.Max( InfillSpacing, 1.0f ) ) )
{
if ( top - bottom < 0.5f ) break;
kit.Box( position + new Vector3( -bar, -bar, bottom ), position + new Vector3( bar, bar, top ) );
}
}
else if ( Infill == RailingInfill.Glass )
{
var glass = MathF.Max( InfillSize, 0.2f ) * 0.5f;
var bottom = LowerRail ? 4.0f + half : 2.0f;
SweepPath( kit, path, Closed, [new( -glass, bottom ), new( glass, bottom ), new( glass, Height - half ), new( -glass, Height - half )] );
}
else if ( Infill == RailingInfill.Wires )
{
var wire = MathF.Max( InfillSize, 0.2f ) * 0.5f;
var bottom = LowerRail ? 4.0f + half : 2.0f;
var top = Height - half;
var steps = Math.Max( 1, (int)MathF.Round( (top - bottom) / MathF.Max( InfillSpacing, 1.0f ) ) );
for ( var w = 1; w <= steps; w++ )
{
var z = bottom + (top - bottom) * w / (steps + 1.0f);
SweepPath( kit, path, Closed, [new( -wire, z ), new( wire, z ), new( wire, z + wire * 2.0f ), new( -wire, z + wire * 2.0f )] );
}
}
}
}
/// <summary>
/// Barbed wire on posts, with slack strands.
/// </summary>
[ToolCategory( "Defense" )]
[Title( "Barbed Wire" ), Icon( "gesture" )]
public sealed class BarbedWireEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override bool SnapsToLines => true;
protected override bool CanClose => true;
protected override int MinPoints => 2;
[Property, Group( "Barbed Wire" ), Range( 8, 256, slider: false ), WideMode]
public float Height { get; set; } = 48.0f;
[Property, Group( "Barbed Wire" ), Title( "Post Spacing" ), Range( 16, 512, slider: false ), WideMode]
public float PostSpacing { get; set; } = 96.0f;
[Property, Group( "Barbed Wire" ), Range( 1, 12, slider: false ), WideMode]
public int Strands { get; set; } = 4;
[Property, Group( "Barbed Wire" ), Range( 0, 16, slider: false ), WideMode]
public float Slack { get; set; } = 3.0f;
[Property, Group( "Barbed Wire" ), Title( "Post Lean" ), Range( 0, 32, slider: false ), WideMode, Description( "How far the tops of the posts lean along the line. Zero keeps them upright." )]
public float PostLean { get; set; } = 6.0f;
[Property, Group( "Barbed Wire" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
protected override bool IsShapeValid => SamplePath().Count >= 2;
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
var random = new Random( Seed );
var posts = WalkPath( path, Closed, MathF.Max( PostSpacing, 16.0f ) );
if ( !Closed && !posts[^1].Position.AlmostEqual( path[^1], 0.01f ) )
posts.Add( (path[^1], (path[^1] - path[^2]).Normal) );
foreach ( var (position, direction) in posts )
{
var lean = direction * PostLean * ((float)random.NextDouble() * 2.0f - 1.0f);
var foot = position;
var top = position + lean + Vector3.Up * Height;
kit.Hex(
foot + new Vector3( -1, -1, 0 ), foot + new Vector3( 1, -1, 0 ), foot + new Vector3( 1, 1, 0 ), foot + new Vector3( -1, 1, 0 ),
top + new Vector3( -1, -1, 0 ), top + new Vector3( 1, -1, 0 ), top + new Vector3( 1, 1, 0 ), top + new Vector3( -1, 1, 0 ) );
}
kit.Material = TrimMaterial;
for ( var i = 0; i < posts.Count - 1; i++ )
{
var a = posts[i].Position;
var b = posts[i + 1].Position;
for ( var s = 1; s <= Strands; s++ )
{
var z = Height * s / (Strands + 1f) + Vector3.Up.z;
var sag = -Slack * (float)random.NextDouble();
var middle = (a + b) * 0.5f + Vector3.Up * (z + sag);
kit.Beam( a + Vector3.Up * z, middle, 0.4f, 0.4f );
kit.Beam( middle, b + Vector3.Up * z, 0.4f, 0.4f );
}
}
}
}
/// <summary>
/// Stacked sandbags along a path, with every other row shifted.
/// </summary>
[ToolCategory( "Defense" )]
[Title( "Sandbags" ), Icon( "view_stream" )]
public sealed class SandbagEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override bool SnapsToLines => true;
protected override bool CanClose => true;
public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Trim" };
protected override int MinPoints => 2;
[Property, Group( "Sandbags" ), Range( 1, 32, slider: false ), WideMode]
public int Rows { get; set; } = 4;
[Property, Group( "Sandbags" ), Title( "Bag Length" ), Range( 4, 128, slider: false ), WideMode]
public float BagLength { get; set; } = 24.0f;
[Property, Group( "Sandbags" ), Title( "Bag Width" ), Range( 2, 128, slider: false ), WideMode]
public float BagWidth { get; set; } = 12.0f;
[Property, Group( "Sandbags" ), Title( "Bag Height" ), Range( 1, 64, slider: false ), WideMode]
public float BagHeight { get; set; } = 6.0f;
[Property, Group( "Sandbags" ), Title( "Random Turn" ), Range( 0, 45, slider: false ), WideMode]
public float RandomTurn { get; set; } = 6.0f;
[Property, Group( "Sandbags" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
protected override bool IsShapeValid => SamplePath().Count >= 2;
protected override void BuildMesh( MeshKit kit )
{
var path = SamplePath();
var random = new Random( Seed );
var spots = WalkPath( path, Closed, MathF.Max( BagLength, 4.0f ) );
var l = BagLength * 0.5f;
var w = BagWidth * 0.5f;
var inset = MathF.Min( l, w ) * 0.2f;
for ( var row = 0; row < Rows; row++ )
{
var shift = row % 2 == 1 ? BagLength * 0.5f : 0.0f;
foreach ( var (position, direction) in spots )
{
var yaw = ((float)random.NextDouble() - 0.5f) * RandomTurn * 2.0f;
var rotation = Rotation.LookAt( direction.WithZ( 0.0f ).Normal, Vector3.Up ) * Rotation.FromYaw( yaw );
var place = position + direction * shift + Vector3.Up * (row * BagHeight);
kit.Frame = new Transform( place, rotation );
kit.Hex(
new( -l + inset, -w + inset, 0 ), new( l - inset, -w + inset, 0 ), new( l - inset, w - inset, 0 ), new( -l + inset, w - inset, 0 ),
new( -l, -w, BagHeight * 0.5f ), new( l, -w, BagHeight * 0.5f ), new( l, w, BagHeight * 0.5f ), new( -l, w, BagHeight * 0.5f ) );
kit.Hex(
new( -l, -w, BagHeight * 0.5f ), new( l, -w, BagHeight * 0.5f ), new( l, w, BagHeight * 0.5f ), new( -l, w, BagHeight * 0.5f ),
new( -l + inset, -w + inset, BagHeight ), new( l - inset, -w + inset, BagHeight ), new( l - inset, w - inset, BagHeight ), new( -l + inset, w - inset, BagHeight ) );
}
}
kit.Frame = new Transform( Vector3.Zero, Rotation.Identity );
}
}
/// <summary>
/// Place copies of a prefab along a path, with random spread.
/// </summary>
[ToolCategory( "Scatter" )]
[Title( "Scatter Prefabs" ), Icon( "scatter_plot" )]
public sealed class ScatterEditor( PrimitiveTool tool ) : PathEditor( tool )
{
protected override int MinPoints => 2;
protected override bool CanClose => true;
protected override bool PlacesObjects => true;
protected override bool IsShapeValid => Prefab is not null && SamplePath().Count >= 2;
[Property, Group( "Scatter" ), WideMode]
public PrefabFile Prefab { get; set; }
[Property, Group( "Scatter" ), Range( 1, 2048, slider: false ), WideMode]
public float Spacing { get; set; } = 64.0f;
[Property, Group( "Scatter" ), Title( "Side Spread" ), Range( 0, 1024, slider: false ), WideMode]
public float SideSpread { get; set; } = 0.0f;
[Property, Group( "Scatter" ), Title( "Follow Path" ), WideMode, Description( "Turns each copy along the path." )]
public bool FollowPath { get; set; } = true;
[Property, Group( "Scatter" ), Title( "Random Turn" ), Range( 0, 180, slider: false ), WideMode]
public float RandomTurn { get; set; } = 0.0f;
[Property, Group( "Scatter" ), Title( "Scale Min" ), Range( 0.05f, 16, slider: false ), WideMode]
public float ScaleMin { get; set; } = 1.0f;
[Property, Group( "Scatter" ), Title( "Scale Max" ), Range( 0.05f, 16, slider: false ), WideMode]
public float ScaleMax { get; set; } = 1.0f;
[Property, Group( "Scatter" ), Title( "Drop To Ground" ), WideMode]
public bool DropToGround { get; set; } = true;
[Property, Group( "Scatter" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
protected override void BuildMesh( MeshKit kit ) { }
public override void OnUpdate( SceneTrace trace )
{
base.OnUpdate( trace );
if ( Points.Count < 2 ) return;
Gizmo.Draw.Color = Color.Cyan;
foreach ( var (position, _) in WalkPath( SamplePath(), Closed, MathF.Max( Spacing, 1.0f ) ) )
Gizmo.Draw.SolidSphere( position, 3.0f );
}
protected override void PlaceObjects()
{
var scene = SceneEditorSession.Active.Scene;
var source = SceneUtility.GetPrefabScene( Prefab );
var random = new Random( Seed );
var root = new GameObject( true, $"Scatter {Prefab.ResourceName}" );
root.WorldPosition = Points[0].Position;
foreach ( var (position, direction) in WalkPath( SamplePath(), Closed, MathF.Max( Spacing, 1.0f ) ) )
{
var left = new Vector3( -direction.y, direction.x, 0.0f ).Normal;
var spot = position + left * (((float)random.NextDouble() - 0.5f) * SideSpread * 2.0f);
if ( DropToGround )
{
var hit = scene.Trace.Ray( spot + Vector3.Up * 256.0f, spot + Vector3.Down * 2048.0f ).Run();
if ( hit.Hit ) spot = hit.EndPosition;
}
var yaw = ((float)random.NextDouble() - 0.5f) * RandomTurn * 2.0f;
var rotation = (FollowPath ? Rotation.LookAt( direction.WithZ( 0.0f ).Normal, Vector3.Up ) : Rotation.Identity) * Rotation.FromYaw( yaw );
var scale = ScaleMin + (ScaleMax - ScaleMin) * (float)random.NextDouble();
var copy = source.Clone( new Transform( spot, rotation, scale ) );
copy.SetParent( root, true );
}
}
}