Editor tool that draws shelving along a wall. A path editor wraps a generator that places bays between shared uprights, shelves, back panel, plinth and top for a bookcase or posts and thin boards for a metal rack, plus a seeded fill of book rows and storage boxes. Corners get clearance so runs do not overlap.
namespace AdvancedMappingTools;
public enum ShelvingStyle
{
Bookcase,
[Title( "Open Shelves" )] Open,
[Title( "Metal Rack" )] Metal
}
public sealed class ShelvingSettings : GeneratorSettings
{
public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 512.0f, 0.0f, 0.0f )] );
[Property, Group( "Shelving" ), Range( 24, 192, slider: false ), WideMode]
public float Height { get; set; } = 84.0f;
[Property, Group( "Shelving" ), Title( "Shelves" ), Range( 1, 8, slider: false ), WideMode, Description( "Shelves above the bottom one." )]
public int Shelves { get; set; } = 4;
[Property, Group( "Shelving" ), Range( 6, 48, slider: false ), WideMode]
public float Depth { get; set; } = 12.0f;
[Property, Group( "Shelving" ), Title( "Fill" ), Range( 0, 1 ), WideMode, Description( "How full the shelves are with books and boxes." )]
public float Fill { get; set; } = 0.7f;
[Property, Group( "Shelving" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
[Property, Group( "Shelving" ), Title( "Style" ), WideMode]
public ShelvingStyle Style { get; set; } = ShelvingStyle.Bookcase;
[Property, Group( "Shelving" ), Title( "Bay Width" ), Range( 12, 96, slider: false ), WideMode, Description( "Width of one section between two uprights." )]
public float BayWidth { get; set; } = 32.0f;
[Property, Group( "Shelving" ), Title( "Books" ), Range( 0, 1 ), WideMode, Description( "Share of the filled space that holds books instead of boxes." )]
public float BookShare { get; set; } = 0.7f;
[Property, Group( "Shelving" ), Title( "Flip Side" ), WideMode, Description( "Builds the shelving on the other side of the drawn line." )]
public bool FlipSide { get; set; } = false;
}
/// <summary>
/// Shelving along a wall: bays between shared uprights, shelves between the uprights, a back, a
/// plinth and a top for a bookcase, posts and thin shelves for a metal rack, and a seeded fill of
/// books in rows and storage boxes. The line is the wall, the shelving stands on its left. At an
/// inside corner the corner belongs to the run before it. No part reaches into another.
/// </summary>
public sealed class ShelvingGenerator : IPartGenerator
{
public string Id => "shelving";
public string Title => "Shelving";
public Type SettingsType => typeof( ShelvingSettings );
const string ShelfCategory = "shelving";
public const float Board = 0.75f;
const float Back = 0.25f;
const float Kick = 3.0f;
const float Post = 1.25f;
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (ShelvingSettings)settings;
var root = context.Group( "root", "Shelving", "Building", ShelfCategory );
if ( s.Path is null || s.Path.Nodes.Count < 2 ) return root;
var points = s.Path.Nodes.Select( x => x.Position ).ToList();
if ( s.FlipSide ) points.Reverse();
var depth = Math.Clamp( s.Depth, 6.0f, 48.0f );
var material = context.Material( null );
var stream = context.Stream( ShelfCategory, $"fill{s.Seed}" );
for ( var i = 0; i + 1 < points.Count; i++ )
{
var a = points[i];
var b = points[i + 1];
var length = a.WithZ( 0.0f ).Distance( b.WithZ( 0.0f ) );
if ( length < 0.01f ) continue;
var dir = (b - a).WithZ( 0.0f ).Normal;
var start = 0.0f;
if ( i > 0 )
{
var prev = (a - points[i - 1]).WithZ( 0.0f ).Normal;
var turn = MathF.Atan2( prev.x * dir.y - prev.y * dir.x, prev.x * dir.x + prev.y * dir.y );
if ( turn > 0.01f ) start = MathF.Min( Clearance( turn, depth ), length );
}
if ( length - start < 12.0f ) continue;
Run( s, context, root, $"run{i:00}", new Transform( a, Rotation.LookAt( dir, Vector3.Up ) ), start, length, depth, material, stream );
}
return root;
}
static float Clearance( float turn, float depth )
{
var sin = MathF.Sin( turn );
var cos = MathF.Cos( turn );
if ( sin < 1e-4f ) return depth;
var past = depth * MathF.Max( 1.0f, 1.0f - cos ) / sin;
var behind = cos > 1e-4f ? depth * sin / cos : float.MaxValue;
return MathF.Min( past, behind );
}
static void Run( ShelvingSettings s, GenContext context, PartPlan root, string id, Transform frame, float start, float length, float depth, Material material, SeedStream stream )
{
var height = MathF.Max( s.Height, 24.0f );
var metal = s.Style == ShelvingStyle.Metal;
var back = s.Style == ShelvingStyle.Bookcase ? Back : 0.0f;
var upright = metal ? Post : Board;
var bays = Math.Max( 1, (int)MathF.Round( (length - start) / MathF.Max( s.BayWidth, 12.0f ) ) );
var pitch = (length - start - upright) / bays;
var levels = Math.Clamp( s.Shelves, 1, 8 );
var bottom = metal ? 4.0f : Kick;
var top = metal ? height - 0.5f : height - Board;
var shelf = metal ? 0.5f : Board;
var gap = (top - bottom - shelf) / levels;
void Solid( string part, string name, string kind, Action<MeshKit> build ) =>
root.Add( context.Solid( $"{id}/{part}", name, kind, ShelfCategory, material, kit =>
{
kit.LiftFlushFaces = false;
kit.Frame = frame;
build( kit );
} ) );
static void Box( MeshKit kit, float x0, float y0, float z0, float x1, float y1, float z1 ) =>
kit.Box( new Vector3( MathF.Min( x0, x1 ), MathF.Min( y0, y1 ), MathF.Min( z0, z1 ) ), new Vector3( MathF.Max( x0, x1 ), MathF.Max( y0, y1 ), MathF.Max( z0, z1 ) ) );
float Upright( int k ) => start + k * pitch;
// Shelf heights: the bottom one on the plinth or the lowest beam, the rest evenly above.
var shelves = Enumerable.Range( 0, levels ).Select( k => bottom + k * gap ).ToList();
Solid( "frame", metal ? "Rack Frame" : "Carcass", "Shelving", kit =>
{
if ( back > 0.0f ) Box( kit, start, 0.0f, 0.0f, length, back, height );
for ( var k = 0; k <= bays; k++ )
{
var x = Upright( k );
if ( metal )
{
// Posts at front and back.
Box( kit, x, 0.0f, 0.0f, x + upright, upright, height );
Box( kit, x, depth - upright, 0.0f, x + upright, depth, height );
}
else Box( kit, x, back, 0.0f, x + upright, depth, height );
}
for ( var k = 0; k < bays; k++ )
{
var x0 = Upright( k ) + upright;
var x1 = Upright( k + 1 );
if ( metal )
{
foreach ( var z in shelves.Append( top ) )
Box( kit, x0, 0.0f, z, x1, depth, z + 0.5f );
}
else
{
Box( kit, x0, back, 0.0f, x1, depth - 0.5f, Kick );
foreach ( var z in shelves.Skip( 1 ) ) Box( kit, x0, back, z, x1, depth, z + shelf );
Box( kit, x0, back, Kick, x1, depth, Kick + shelf );
Box( kit, x0, back, top, x1, depth, height );
}
}
} );
// The fill, standing on every shelf: rows of books and storage boxes.
var fill = Math.Clamp( s.Fill, 0.0f, 1.0f );
var books = new List<(float X0, float X1, float Z, float H, float D)>();
var boxes = new List<(float X0, float X1, float Z, float H, float D)>();
var surface = metal ? 0.5f : shelf;
var front = depth - 0.5f;
for ( var k = 0; k < bays; k++ )
{
var x0 = Upright( k ) + upright + 0.3f;
var x1 = Upright( k + 1 ) - 0.3f;
for ( var l = 0; l < levels; l++ )
{
var z = shelves[l] + surface;
var clear = (l + 1 < levels ? shelves[l + 1] : top) - z - 0.5f;
if ( clear < 3.0f || stream.Next() > fill ) continue;
var x = x0;
while ( x < x1 - 1.0f )
{
if ( stream.Next() < s.BookShare )
{
// A row of books, then some air.
var count = (int)stream.Range( 3, 14 );
for ( var n = 0; n < count && x < x1 - 0.6f; n++ )
{
var w = MathF.Min( stream.Range( 0.7f, 2.0f ), x1 - x );
var h = MathF.Min( stream.Range( 0.6f, 0.95f ) * clear, clear );
var d = MathF.Min( stream.Range( 5.5f, 8.5f ), front - back - 0.5f );
books.Add( (x, x + w, z, h, d) );
x += w + 0.02f;
}
}
else
{
var w = MathF.Min( stream.Range( 6.0f, 11.0f ), x1 - x );
if ( w < 3.0f ) break;
var h = MathF.Min( stream.Range( 0.45f, 0.85f ) * clear, clear );
var d = MathF.Min( depth * stream.Range( 0.7f, 0.9f ), front - back - 0.5f );
boxes.Add( (x, x + w, z, h, d) );
x += w + 0.02f;
}
x += stream.Range( 0.5f, 6.0f );
}
}
}
if ( books.Count > 0 )
Solid( "books", "Books", "Books", kit => { foreach ( var (x0, x1, z, h, d) in books ) Box( kit, x0, front - d, z, x1, front, z + h ); } );
if ( boxes.Count > 0 )
Solid( "boxes", "Storage Boxes", "Boxes", kit => { foreach ( var (x0, x1, z, h, d) in boxes ) Box( kit, x0, front - d, z, x1, front, z + h ); } );
}
}
/// <summary>
/// Draws shelving along a wall. The line is the wall, the shelving stands on its left, or on its
/// right with Flip Side. Corners fit together.
/// </summary>
[ToolCategory( "Interior" )]
[Title( "Shelving" ), Icon( "view_list" )]
public sealed class ShelvingEditor : PathEditor
{
readonly ShelvingGenerator _generator = new();
public ShelvingEditor( PrimitiveTool tool ) : base( tool ) { }
public ShelvingSettings Settings { get; } = new();
public override object ExtraSettings => Settings;
protected override int MinPoints => 2;
protected override bool PlacesObjects => true;
protected override bool AllowsHeight => true;
protected override bool UsesBends => false;
protected override IEnumerable<string> Hints => ["Click along the wall", "The shelving stands on the left of the line, Flip Side puts it on the right"];
protected override void BuildMesh( MeshKit kit ) { }
void Prepare() => Settings.Path = Curve();
protected override PolygonMesh BuildPreview()
{
Prepare();
return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
}
protected override void PlaceObjects()
{
Prepare();
PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
}
}