editor/AdvancedMappingTools/Architecture/KitchenCounterEditor.cs

Editor tool that generates a fitted kitchen counter along a drawn path. Defines settings, a part generator that builds cabinets, worktop, sink, hob, oven, hood and upper cabinets, and a PathEditor that previews and places the result.

namespace AdvancedMappingTools;

public sealed class KitchenCounterSettings : GeneratorSettings
{
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 512.0f, 0.0f, 0.0f )] );

	[Property, Group( "Kitchen" ), Range( 12, 48, slider: false ), WideMode, Description( "Depth of the counter from the wall, worktop included." )]
	public float Depth { get; set; } = 25.0f;

	[Property, Group( "Kitchen" ), Range( 24, 48, slider: false ), WideMode, Description( "Height of the worktop surface." )]
	public float Height { get; set; } = 36.0f;

	[Property, Group( "Kitchen" ), Title( "Cabinet Width" ), Range( 9, 48, slider: false ), WideMode]
	public float CabinetWidth { get; set; } = 24.0f;

	[Property, Group( "Kitchen" ), Title( "Upper Cabinets" ), WideMode]
	public bool UpperCabinets { get; set; } = true;

	[Property, Group( "Kitchen" ), WideMode, Description( "A recessed plinth under the base cabinets." )]
	public bool Plinth { get; set; } = true;

	[Property, Group( "Kitchen" ), WideMode, Description( "A sink let into the worktop, with a tap." )]
	public bool Sink { get; set; } = true;

	[Property, ShowIf( nameof( Sink ), true ), Group( "Kitchen" ), Title( "Sink Position" ), Range( 0, 1 ), WideMode, Description( "Where along the whole counter the sink sits." )]
	public float SinkPosition { get; set; } = 0.3f;

	[Property, Group( "Kitchen" ), Title( "Hob" ), WideMode, Description( "A hob with four rings on the worktop, an oven below and a hood above." )]
	public bool Hob { get; set; } = true;

	[Property, ShowIf( nameof( Hob ), true ), Group( "Kitchen" ), Title( "Hob Position" ), Range( 0, 1 ), WideMode]
	public float HobPosition { get; set; } = 0.7f;

	[Property, Group( "Materials" ), Title( "Cabinet" ), ResourceType( "vmat" ), WideMode]
	public string CabinetMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Top" ), ResourceType( "vmat" ), WideMode]
	public string TopMaterial { get; set; }

	[Property, Group( "Kitchen" ), Title( "Backsplash" ), WideMode, Description( "A tiled strip on the wall between worktop and upper cabinets." )]
	public bool Backsplash { get; set; } = true;

	[Property, Group( "Kitchen" ), Title( "Flip Side" ), WideMode, Description( "Builds the counter on the other side of the drawn line." )]
	public bool FlipSide { get; set; } = false;

	[Property, Group( "Materials" ), Title( "Metal" ), ResourceType( "vmat" ), WideMode, Description( "Sink, tap, hob, oven and handles." )]
	public string MetalMaterial { get; set; }
}

/// <summary>
/// A fitted kitchen along a wall. The drawn line is the wall, the counter stands on its left
/// (or right with Flip Side). Every straight run holds cabinets of even width: a carcass, a drawer
/// and a door front with handles, a recessed plinth, the worktop with overhang and a cut out for a
/// sunk sink with a tap, a hob with an oven and a hood, upper cabinets and a backsplash. At an
/// inside corner the corner belongs to the run before it, so nothing overlaps.
/// </summary>
public sealed class KitchenCounterGenerator : IPartGenerator
{
	public string Id => "kitchencounter";
	public string Title => "Kitchen Counter";
	public Type SettingsType => typeof( KitchenCounterSettings );

	const string KitchenCategory = "kitchen";

	public const float TopThickness = 1.5f;
	const float Overhang = 1.0f;
	const float FrontThickness = 0.75f;
	const float Gap = 0.15f;
	const float PlinthHeight = 4.0f;
	const float PlinthSetBack = 3.0f;
	const float DrawerHeight = 6.0f;
	public const float BasinDepth = 8.0f;
	public const float UpperGap = 18.0f;
	public const float UpperHeight = 30.0f;
	const float UpperDepth = 12.0f;

	enum Slot { Plain, Sink, Hob }

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (KitchenCounterSettings)settings;
		var root = context.Group( "root", "Kitchen", "Building", KitchenCategory );

		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, 12.0f, 48.0f );
		var height = Math.Clamp( s.Height, 24.0f, 48.0f );
		var cabinetMat = context.Material( s.CabinetMaterial );
		var topMat = context.Material( s.TopMaterial );
		var metalMat = context.Material( s.MetalMaterial );
		var total = 0.0f;
		for ( var i = 0; i + 1 < points.Count; i++ ) total += points[i].WithZ( 0 ).Distance( points[i + 1].WithZ( 0 ) );
		if ( total < 1.0f ) return root;

		var sinkAt = Math.Clamp( s.SinkPosition, 0.0f, 1.0f ) * total;
		var hobAt = Math.Clamp( s.HobPosition, 0.0f, 1.0f ) * total;
		var walked = 0.0f;
		var sinkPlaced = !s.Sink;
		var hobPlaced = !s.Hob;

		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 frame = new Transform( a, Rotation.LookAt( dir, Vector3.Up ) );

			// An inside corner before this run: the run before keeps the corner, this one starts after it.
			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 = CornerClearance( turn, depth );
			}

			var runFrom = walked;
			walked += length;
			if ( length - start < 9.0f ) continue;

			var n = Math.Max( 1, (int)MathF.Round( (length - start) / MathF.Max( s.CabinetWidth, 9.0f ) ) );
			var w = (length - start) / n;
			var slots = new Slot[n];
			for ( var j = 0; j < n; j++ )
			{
				var mid = runFrom + start + (j + 0.5f) * w;
				if ( !sinkPlaced && MathF.Abs( mid - sinkAt ) <= w * 0.5f + 0.01f ) { slots[j] = Slot.Sink; sinkPlaced = true; }
				else if ( !hobPlaced && MathF.Abs( mid - hobAt ) <= w * 0.5f + 0.01f ) { slots[j] = Slot.Hob; hobPlaced = true; }
			}

			Run( s, context, root, $"run{i:00}", frame, start, length, w, slots, depth, height, cabinetMat, topMat, metalMat );
		}

		return root;
	}

	/// <summary>
	/// How far the next run starts from a corner turning by <paramref name="turn"/> radians towards the counter.
	/// </summary>
	static float CornerClearance( 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( KitchenCounterSettings s, GenContext context, PartPlan root, string id, Transform frame, float start, float length, float w, Slot[] slots,
		float depth, float height, Material cabinetMat, Material topMat, Material metalMat )
	{
		var plinth = s.Plinth ? PlinthHeight : 0.0f;
		var underTop = height - TopThickness;
		var carcassFront = depth - Overhang - FrontThickness;
		var frontFace = carcassFront + FrontThickness;

		void Solid( string part, string name, string kind, Material m, Action<MeshKit> build ) =>
			root.Add( context.Solid( $"{id}/{part}", name, kind, KitchenCategory, m, kit =>
			{
				// Neighbouring boxes of one part meet face to face on purpose.
				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 X0( int j ) => start + j * w;
		float X1( int j ) => start + (j + 1) * w;
		var sinkOpening = (from: 0.0f, to: 0.0f, slot: -1);
		for ( var j = 0; j < slots.Length; j++ )
			if ( slots[j] == Slot.Sink ) sinkOpening = (X0( j ) + 3.0f, X1( j ) - 3.0f, j);
		var hasSink = sinkOpening.slot >= 0 && sinkOpening.to - sinkOpening.from > 6.0f;
		var basinBack = 3.0f;
		var basinFront = depth - 5.0f;

		if ( plinth > 0.0f )
			Solid( "plinth", "Plinth", "Plinth", cabinetMat, kit => Box( kit, start, 0, 0, length, carcassFront - PlinthSetBack, plinth ) );

		// Carcasses, one per slot; the sink one is lower so the basin hangs into it.
		Solid( "carcasses", "Base Cabinets", "Cabinet", cabinetMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
			{
				var top = hasSink && j == sinkOpening.slot ? underTop - BasinDepth : underTop;
				Box( kit, X0( j ), 0, plinth, X1( j ), carcassFront, top );
			}
		} );

		// Fronts: a drawer over a door, an oven under the hob, a false front over the sink door.
		Solid( "fronts", "Fronts", "CabinetFront", cabinetMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
			{
				var x0 = X0( j ) + Gap;
				var x1 = X1( j ) - Gap;
				var drawerLow = underTop - DrawerHeight - Gap;
				Box( kit, x0, carcassFront, drawerLow, x1, frontFace, underTop - Gap );
				if ( slots[j] != Slot.Hob ) Box( kit, x0, carcassFront, plinth + Gap, x1, frontFace, drawerLow - Gap );
			}
		} );

		Solid( "handles", "Handles", "Handle", metalMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
			{
				var mid = (X0( j ) + X1( j )) * 0.5f;
				var half = MathF.Min( w * 0.2f, 4.0f );
				var drawerMid = underTop - Gap - DrawerHeight * 0.5f;
				Box( kit, mid - half, frontFace, drawerMid - 0.25f, mid + half, frontFace + 0.75f, drawerMid + 0.25f );
				var doorTop = underTop - DrawerHeight - Gap * 2.0f;
				if ( slots[j] != Slot.Hob ) Box( kit, mid - half, frontFace, doorTop - 2.25f, mid + half, frontFace + 0.75f, doorTop - 1.75f );
			}
		} );

		// The worktop, with a cut out around the sink.
		Solid( "top", "Worktop", "Worktop", topMat, kit =>
		{
			if ( !hasSink ) { Box( kit, start, 0, underTop, length, depth, height ); return; }
			var (from, to, _) = sinkOpening;
			Box( kit, start, 0, underTop, from, depth, height );
			Box( kit, to, 0, underTop, length, depth, height );
			Box( kit, from, 0, underTop, to, basinBack, height );
			Box( kit, from, basinFront, underTop, to, depth, height );
		} );

		if ( hasSink )
		{
			var (from, to, _) = sinkOpening;
			var mid = (from + to) * 0.5f;
			Solid( "sink", "Sink", "Sink", metalMat, kit => Box( kit, from, basinBack, underTop - BasinDepth, to, basinFront, underTop ) );
			Solid( "tap", "Tap", "Tap", metalMat, kit =>
			{
				kit.Cylinder( new Vector3( mid, 1.5f, 0 ), 0.9f, 0.6f, height, height + 11.0f, 10 );
				kit.Beam( new Vector3( mid, 1.5f, height + 11.5f ), new Vector3( mid, 8.0f, height + 11.5f ), 1.0f, 1.0f );
			} );
		}

		for ( var j = 0; j < slots.Length; j++ )
		{
			if ( slots[j] != Slot.Hob ) continue;
			var x0 = X0( j ) + 1.5f;
			var x1 = X1( j ) - 1.5f;
			var y0 = depth * 0.12f;
			var y1 = depth - 3.0f;
			Solid( $"hob{j}", "Hob", "Hob", metalMat, kit => Box( kit, x0, y0, height, x1, y1, height + 0.3f ) );
			Solid( $"hob{j}_rings", "Hob Rings", "Hob", metalMat, kit =>
			{
				var r = MathF.Min( (x1 - x0) * 0.18f, (y1 - y0) * 0.18f );
				foreach ( var (fx, fy) in new[] { (0.28f, 0.3f), (0.72f, 0.3f), (0.28f, 0.72f), (0.72f, 0.72f) } )
					kit.Cylinder( new Vector3( x0 + (x1 - x0) * fx, y0 + (y1 - y0) * fy, 0 ), r, r, height + 0.3f, height + 0.45f, 16 );
			} );

			// The oven: a door with a window panel and a bar handle.
			var ox0 = X0( j ) + Gap;
			var ox1 = X1( j ) - Gap;
			var oz0 = plinth + Gap;
			var oz1 = underTop - DrawerHeight - Gap * 2.0f;
			Solid( $"oven{j}", "Oven Door", "Oven", metalMat, kit => Box( kit, ox0, carcassFront, oz0, ox1, frontFace, oz1 ) );
			Solid( $"oven{j}_window", "Oven Window", "Oven", cabinetMat, kit => Box( kit, ox0 + 3.0f, frontFace, oz0 + 4.0f, ox1 - 3.0f, frontFace + 0.1f, oz1 - 6.0f ) );
			Solid( $"oven{j}_handle", "Oven Handle", "Handle", metalMat, kit => Box( kit, ox0 + 2.0f, frontFace, oz1 - 2.5f, ox1 - 2.0f, frontFace + 1.0f, oz1 - 1.5f ) );
		}

		var upperLow = height + UpperGap;
		var upperHigh = upperLow + UpperHeight;

		if ( s.Backsplash )
			Solid( "backsplash", "Backsplash", "Backsplash", topMat, kit => Box( kit, start, 0, height, length, 0.5f, upperLow ) );

		if ( !s.UpperCabinets ) return;

		Solid( "uppers", "Upper Cabinets", "Cabinet", cabinetMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
				if ( slots[j] != Slot.Hob ) Box( kit, X0( j ), s.Backsplash ? 0.5f : 0.0f, upperLow, X1( j ), UpperDepth - FrontThickness, upperHigh );
		} );

		Solid( "upper_fronts", "Upper Fronts", "CabinetFront", cabinetMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
				if ( slots[j] != Slot.Hob ) Box( kit, X0( j ) + Gap, UpperDepth - FrontThickness, upperLow + Gap, X1( j ) - Gap, UpperDepth, upperHigh - Gap );
		} );

		Solid( "upper_handles", "Upper Handles", "Handle", metalMat, kit =>
		{
			for ( var j = 0; j < slots.Length; j++ )
			{
				if ( slots[j] == Slot.Hob ) continue;
				var mid = (X0( j ) + X1( j )) * 0.5f;
				var half = MathF.Min( w * 0.2f, 4.0f );
				Box( kit, mid - half, UpperDepth, upperLow + 1.75f, mid + half, UpperDepth + 0.75f, upperLow + 2.25f );
			}
		} );

		// The hood over the hob: a canopy and a chimney up to the top of the cabinets.
		for ( var j = 0; j < slots.Length; j++ )
		{
			if ( slots[j] != Slot.Hob ) continue;
			var x0 = X0( j ) + 1.0f;
			var x1 = X1( j ) - 1.0f;
			var back = s.Backsplash ? 0.5f : 0.0f;
			var cx = (x0 + x1) * 0.5f;
			var chimney = MathF.Min( (x1 - x0) * 0.25f, 5.0f );
			Solid( $"hood{j}", "Hood", "Hood", metalMat, kit =>
				kit.Hex( new Vector3( x0, back, upperLow + 4.0f ), new Vector3( x1, back, upperLow + 4.0f ), new Vector3( x1, 20.0f, upperLow + 4.0f ), new Vector3( x0, 20.0f, upperLow + 4.0f ),
					new Vector3( cx - chimney, back, upperLow + 10.0f ), new Vector3( cx + chimney, back, upperLow + 10.0f ), new Vector3( cx + chimney, 8.0f, upperLow + 10.0f ), new Vector3( cx - chimney, 8.0f, upperLow + 10.0f ) ) );
			Solid( $"hood{j}_chimney", "Hood Chimney", "Hood", metalMat, kit => Box( kit, cx - chimney, back, upperLow + 10.0f, cx + chimney, 8.0f, upperHigh ) );
		}
	}
}

/// <summary>
/// Draws a fitted kitchen along a wall. The line is the wall, the counter stands on its left,
/// or on its right with Flip Side. Corners of L and U kitchens fit together.
/// </summary>
[ToolCategory( "Interior" )]
[Title( "Kitchen Counter" ), Icon( "countertops" )]
public sealed class KitchenCounterEditor : PathEditor
{
	readonly KitchenCounterGenerator _generator = new();

	public KitchenCounterEditor( PrimitiveTool tool ) : base( tool ) { }

	public KitchenCounterSettings 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 counter 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();
		Settings.CabinetMaterial = PartTool.Pick( Settings.CabinetMaterial, SurfaceMaterial );
		Settings.TopMaterial = PartTool.Pick( Settings.TopMaterial, TrimMaterial );
		Settings.MetalMaterial = PartTool.Pick( Settings.MetalMaterial, TrimMaterial );
	}

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