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