editor/AdvancedMappingTools/Architecture/ShelvingEditor.cs

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 ) );
	}
}