Editor/Designers/ArchCabinetDesigner.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// THE ONE CABINET DESIGN. A carcass is either extruded by the tool or supplied as art, and both are authored here as
// a sequence rather than as two windows - the first step is the fork and every step after it belongs to the half it
// answered. Save writes back to .cabinet.json, so what is tuned here becomes the shared type.
//
// The right-hand side swaps with the sequence: a generated type is judged as a short run through the production
// generators, and art is judged in the module it will actually stand in, aimed at the same way a front is.
public sealed class ArchCabinetDesigner : ArchDesignerDialog {
readonly ArchTool tool;
readonly Action<ArchCabinetType> applied;
readonly ArchCabinetType draft;
readonly ArchKit staging;
const string Flow = "cabinet.designer";
CabinetSlot slot = CabinetSlot.Module;
ArchAimedPrefabStage aim;
bool named;
public ArchCabinetDesigner( Widget parent, ArchTool tool, ArchCabinetType initial, Action<ArchCabinetType> applied )
: base( parent, "Cabinet Designer", "kitchen" ) {
this.tool = tool;
this.applied = applied;
draft = ArchCabinetTypes.Copy( initial ?? ArchCabinetTypes.Find( tool?.Kit, "" ) );
named = !string.IsNullOrWhiteSpace( draft.Name );
staging = ArchCabinetStage.Staging( tool?.Kit, draft );
// Opening the designer starts the sequence over, or a second design resumes on whichever step the last one
// was left on - and the statics outlive a hotload.
ArchWorkflow.Restart( Flow );
Assemble(
staging,
"Live generated run",
$"Drag the preview to orbit. {ArchCabinetStage.Modules} modules of the type you are designing, through the "
+ "same carcass, front and worktop generators the plan builds with.",
$"Cabinet types: Assets/{ArchCabinetCatalog.Directory}" );
}
protected override string SaveLabel => "Save Type";
protected override ArchStaged Staged() => ArchCabinetStage.Of( draft );
// The second viewport, standing in the same slot as the generated stage and shown only while a prefab is being
// aimed. It reads whichever slot's art the Art step is holding, so switching slot re-aims without a rebuild.
protected override Widget Aiming( Widget parent ) {
aim = new ArchAimedPrefabStage( parent, () => Held(), Anchored, Room );
return aim;
}
BBox Room() {
return ArchAimedPrefabMount.Of( MathF.Max( 4f, draft.Depth ), MathF.Max( 6f, draft.Width ),
MathF.Max( 8f, draft.Plinth + draft.Carcass + draft.Counter ) );
}
protected override void Populate() {
Aim( false, "Live generated run", "Drag the preview to orbit." );
var panel = new ToolSidebarWidget();
Form.Layout.Add( panel );
using var flow = ArchWorkflow.In( panel, Flow, Repopulate );
flow.Step( "Source", draft.Prefabbed ? "Prefab" : "Procedural", draft.Prefabbed ? "category" : "grid_4x4", PickSource );
flow.Step( "Cabinet", draft.Tier.ToString(), ArchCabinetUi.Glyph( draft.Tier ), PickTier );
if ( draft.Prefabbed ) {
flow.Step( "Art", ArtName, "widgets", PickArt );
flow.Step( "Anchor", AnchorName, "my_location", BuildAnchor );
flow.Step( "Finish", FinishName, "palette", BuildFinish );
flow.Step( "Module", SizesName, "straighten", BuildModule );
} else {
flow.Step( "Front", ArchCabinetUi.Named( draft.On( ArchCabinetMounts.Front ) ), "border_all", PickFront );
flow.Step( "Handles", draft.Handles ? draft.Grip.ToString() : "None", "drag_indicator", PickGrip );
flow.Step( "Inside", InsideName, "inventory_2", BuildInside );
flow.Step( "Sizes", SizesName, "straighten", BuildSizes );
flow.Step( "Top", TopName, "countertops", BuildTop );
}
flow.Step( "Identity", string.IsNullOrWhiteSpace( draft.Name ) ? "Unnamed" : draft.Name, "badge", BuildIdentity );
}
void PickSource( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Procedural — the tool extrudes the box, the plinth, the doors and the top", "cabinet_made",
"grid_4x4", !draft.Prefabbed, () => Answer( () => draft.Prefabbed = false, step ) );
grid.Pick( "Prefab — your own art, placed by the same rules a generated run is", "cabinet_art",
"category", draft.Prefabbed, () => Answer( Adopt, step ) );
}
// The art SURVIVES a trip back through Procedural, so an author comparing the two halves does not lose what they
// dropped. A module slot is opened on the way in, or the Art step has nothing to hang the first prefab on.
void Adopt() {
draft.Prefabbed = true;
draft.Carcasses ??= new List<ArchCabinetCarcass>();
if ( !draft.Carcasses.Any( carcass => carcass.Slot == CabinetSlot.Module ) ) {
draft.Carcasses.Add( new ArchCabinetCarcass { Slot = CabinetSlot.Module } );
}
slot = CabinetSlot.Module;
}
void PickTier( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Base — on the floor, counter over it", "designed_tier_base", "table_restaurant",
draft.Tier == CabinetTier.Base, () => Answer( () => draft.Tier = CabinetTier.Base, step ) );
grid.Pick( "Hanging — off the counter below, or the standard datum", "designed_tier_hanging", "shelves",
draft.Tier == CabinetTier.Hanging, () => Answer( () => draft.Tier = CabinetTier.Hanging, step ) );
grid.Pick( "Tall — floor to whatever covers it", "designed_tier_tall", "door_sliding",
draft.Tier == CabinetTier.Tall, () => Answer( () => draft.Tier = CabinetTier.Tall, step ) );
}
string ArtName {
get {
var standing = draft.Carcasses.Count( carcass => carcass.Standing );
return standing == 0 ? "Nothing dropped" : $"{standing} of {Enum.GetValues<CabinetSlot>().Length} slots";
}
}
// One slot at a time, because a run stands a different model in its corner from the one it stands in its body
// and both are the same question asked twice.
void PickArt( ArchWorkflowStep step ) {
using ( var grid = ArchIconGrid.In( step.Layout ) ) {
Slot( grid, CabinetSlot.Module, "Module — the carcass in the body of the run", "view_module" );
Slot( grid, CabinetSlot.Corner, "Corner — the unit filling the square where two runs meet", "join_inner" );
Slot( grid, CabinetSlot.End, "End — the unit on a run that met no other run", "last_page" );
Slot( grid, CabinetSlot.Filler, "Filler — the make-up carcass a run that would not divide evenly takes", "width_normal" );
}
step.Layout.Add( new ArchPrefabWell( Form, () => Held()?.Prefab ?? "", Took ) );
step.Layout.Add( Note( Standing() ) );
}
void Slot( ArchIconGrid grid, CabinetSlot which, string tooltip, string glyph ) {
var dressed = draft.In( which ) is { Standing: true } ? " ✓" : "";
grid.Pick( tooltip + dressed, $"cabinet_slot_{which}".ToLowerInvariant(), glyph, slot == which, () => {
slot = which;
Repopulate();
} );
}
string Standing() {
if ( Held() is not { Standing: true } carcass ) {
return $"Nothing in the {slot} slot — it falls back to the module's own art.";
}
return $"{slot}: {carcass.Prefab}";
}
// The art for the slot being edited, made on the WRITE rather than the read, or merely opening the step would
// file an empty slot the run then has to fall back out of.
ArchCabinetCarcass Held() {
return draft.Carcasses?.FirstOrDefault( carcass => carcass.Slot == slot );
}
ArchCabinetCarcass Own() {
if ( Held() is { } standing ) {
return standing;
}
var made = new ArchCabinetCarcass { Slot = slot };
draft.Carcasses ??= new List<ArchCabinetCarcass>();
draft.Carcasses.Add( made );
return made;
}
void Took( string path ) {
var carcass = Own();
carcass.Prefab = path ?? "";
carcass.Anchor = Vector3.Zero;
carcass.AnchorAlong = Vector3.Zero;
if ( !named ) {
draft.Name = ArchCabinetFronts.Titled( carcass.Prefab );
}
Repopulate();
}
string AnchorName {
get {
if ( Held() is not { Standing: true } carcass ) {
return "No art";
}
return carcass.Anchor == Vector3.Zero && carcass.AnchorAlong == Vector3.Zero ? "Own origin" : "Anchored";
}
}
// THE STEP THAT SWAPS THE VIEWPORT. A closed step never builds, so the generated stage comes back by itself the
// moment the author moves on - nothing has to remember to put it back.
void BuildAnchor( ArchWorkflowStep step ) {
if ( Held() is not { Standing: true } carcass ) {
step.Layout.Add( Note( "Drop a prefab into a slot before anchoring it." ) );
return;
}
Aim( true, "Your prefab, standing in its module",
"Left-click an edge or a corner to anchor it — the aim snaps to real geometry. Right-drag to orbit." );
step.Layout.Add( Note( Aimed( carcass ) ) );
step.Layout.Add( new Button( "Clear the anchor", "backspace" ) { Clicked = () => Anchored( Vector3.Zero, Vector3.Zero ) } );
using ( var grid = ArchIconGrid.In( step.Layout ) ) {
Turn( grid, carcass, "Left — a quarter turn anticlockwise", "cabinet_turn_left", "rotate_left", new Angles( 0f, 90f, 0f ) );
Turn( grid, carcass, "Right — a quarter turn clockwise", "cabinet_turn_right", "rotate_right", new Angles( 0f, -90f, 0f ) );
Turn( grid, carcass, "Flip — face the other way", "cabinet_flip", "flip", new Angles( 0f, 180f, 0f ) );
Turn( grid, carcass, "Tip — a quarter turn onto its face", "cabinet_tip", "screen_rotation", new Angles( 90f, 0f, 0f ) );
Turn( grid, carcass, "Roll — a quarter turn on its own axis", "cabinet_roll", "rotate_90_degrees_ccw", new Angles( 0f, 0f, 90f ) );
grid.Pick( "Square — back to the tool's own guess", "cabinet_untwist", "restart_alt",
carcass.AnchorTurn == default, () => {
carcass.AnchorTurn = default;
Repopulate();
} );
}
using var sizing = ArchIconGrid.In( step.Layout );
sizing.Pick( "Modelled — stands at the size it was drawn", "cabinet_modelled", "straighten", !carcass.Fits, () => {
carcass.Fits = false;
Repopulate();
} );
sizing.Pick( "Stretched — scaled into the module it fills", "cabinet_stretched", "open_in_full", carcass.Fits, () => {
carcass.Fits = true;
Repopulate();
} );
}
void Turn( ArchIconGrid grid, ArchCabinetCarcass carcass, string tooltip, string slug, string glyph, Angles step ) {
grid.Pick( tooltip, slug, glyph, false, () => {
carcass.AnchorTurn = (carcass.AnchorTurn.ToRotation() * step.ToRotation()).Angles();
Repopulate();
} );
}
static string Aimed( ArchCabinetCarcass carcass ) {
if ( carcass.Anchor == Vector3.Zero && carcass.AnchorAlong == Vector3.Zero ) {
return "Not anchored — the prefab's own origin lands on the mount. Click an edge or a corner to change that.";
}
return carcass.AnchorAlong.Length > 0.01f
? $"Anchored on an edge at {carcass.Anchor.x:0.##}, {carcass.Anchor.y:0.##}, {carcass.Anchor.z:0.##}."
: $"Anchored on a corner at {carcass.Anchor.x:0.##}, {carcass.Anchor.y:0.##}, {carcass.Anchor.z:0.##}.";
}
void Anchored( Vector3 anchor, Vector3 along ) {
var carcass = Own();
carcass.Anchor = anchor;
carcass.AnchorAlong = along;
Repopulate();
}
string FinishName => string.IsNullOrWhiteSpace( draft.MaterialGroup ) ? "As modelled" : draft.MaterialGroup;
// The groups the ART itself offers, read off the model rather than typed - a name that is not on the model is a
// group that silently does nothing.
void BuildFinish( ArchWorkflowStep step ) {
var offered = Groups();
if ( offered.Count == 0 ) {
step.Layout.Add( Note( "This prefab's model carries no material groups." ) );
return;
}
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "As modelled — whatever the prefab was authored with", "cabinet_group_none", "block",
string.IsNullOrWhiteSpace( draft.MaterialGroup ), () => Answer( () => draft.MaterialGroup = "", step ) );
foreach ( var group in offered ) {
var standing = group;
grid.Pick( $"{standing} — every carcass in the run wears it", $"cabinet_group_{standing}".ToLowerInvariant(),
"palette", string.Equals( draft.MaterialGroup, standing, StringComparison.OrdinalIgnoreCase ),
() => Answer( () => draft.MaterialGroup = standing, step ) );
}
}
List<string> Groups() => ArchPrefabGroups.Offered( draft.In( CabinetSlot.Module )?.Prefab );
void PickFront( ArchWorkflowStep step ) {
ArchCabinetUi.Front( step.Layout, Fronted(), step.Chose, Stage.Restage );
}
ArchCabinetFitting Fronted() {
if ( draft.On( ArchCabinetMounts.Front ) is { } standing ) {
return standing;
}
var made = new ArchCabinetFitting { Mount = ArchCabinetMounts.Front, Front = CabinetFront.Slab };
draft.Fittings ??= new List<ArchCabinetFitting>();
draft.Fittings.Add( made );
return made;
}
void PickGrip( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "None — no ironmongery at all", "cabinet_grip_none", "block", !draft.Handles,
() => Answer( () => draft.Handles = false, step ) );
Grip( grid, step, CabinetGrip.Bar, "Bar — a rail on two stems", "drag_indicator" );
Grip( grid, step, CabinetGrip.Knob, "Knob — a single stub on the stile", "radio_button_checked" );
Grip( grid, step, CabinetGrip.Cup, "Cup — a shallow pull under a lip", "download" );
}
void Grip( ArchIconGrid grid, ArchWorkflowStep step, CabinetGrip style, string tooltip, string glyph ) {
grid.Pick( tooltip, $"cabinet_grip_{style}".ToLowerInvariant(), glyph, draft.Handles && draft.Grip == style,
() => Answer( () => {
draft.Handles = true;
draft.Grip = style;
}, step ) );
}
string InsideName => ArchPartUi.Listed( (draft.Interior, "interior"), (draft.Shelves > 0, "shelves"),
(draft.DrawerRows > 0, "drawers") );
void BuildInside( ArchWorkflowStep step ) {
step.Layout.Add( Check( "Interior — real panels and shelves behind the doors", draft.Interior,
value => draft.Interior = value ) );
step.Layout.Add( Integer( "Shelves", draft.Shelves, value => draft.Shelves = Math.Max( 0, value ) ) );
step.Layout.Add( Integer( "Drawer rows", draft.DrawerRows, value => draft.DrawerRows = Math.Max( 0, value ) ) );
}
string SizesName => $"{draft.Width:0} × {draft.Depth:0}";
void BuildSizes( ArchWorkflowStep step ) {
BuildModule( step );
step.Layout.Add( Number( "Plinth", draft.PlinthHeight, value => draft.PlinthHeight = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Plinth recess", draft.PlinthRecess, value => draft.PlinthRecess = MathF.Max( 0f, value ) ) );
}
void BuildModule( ArchWorkflowStep step ) {
step.Layout.Add( Number( "Module width", draft.Width, value => draft.Width = MathF.Max( 6f, value ) ) );
step.Layout.Add( Number( "Depth", draft.Depth, value => draft.Depth = MathF.Max( 4f, value ) ) );
step.Layout.Add( Number( "Carcass height", draft.Height, value => draft.Height = MathF.Max( 4f, value ) ) );
if ( draft.Tier == CabinetTier.Hanging ) {
step.Layout.Add( Number( "Hangs at", draft.Datum, value => draft.Datum = MathF.Max( 0f, value ) ) );
}
}
string TopName {
get {
if ( draft.CorniceHeight > 0.01f && draft.Counter <= 0.01f ) {
return $"Crown {draft.CorniceHeight:0.##}";
}
return draft.Counter > 0.01f ? $"{draft.Counter:0.##} thick" : "Bare";
}
}
void BuildTop( ArchWorkflowStep step ) {
step.Layout.Add( Number( "Worktop", draft.TopThickness, value => draft.TopThickness = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Past the front", draft.TopOversail, value => draft.TopOversail = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Past the ends", draft.TopEndOversail, value => draft.TopEndOversail = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Past the back", draft.TopBackOversail, value => draft.TopBackOversail = MathF.Max( 0f, value ) ) );
step.Layout.Add( Heading( "Cornice" ) );
step.Layout.Add( Note( "A run carries a crown or a counter, never both — a worktop over 0 takes it over." ) );
step.Layout.Add( Number( "Crown", draft.CorniceHeight, value => draft.CorniceHeight = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Projection", draft.CorniceProjection, value => draft.CorniceProjection = MathF.Max( 0f, value ) ) );
step.Layout.Add( Number( "Past the ends", draft.CorniceOversail, value => draft.CorniceOversail = MathF.Max( 0f, value ) ) );
}
void BuildIdentity( ArchWorkflowStep step ) {
step.Layout.Add( Text( "Name", draft.Name, value => {
draft.Name = value;
named = !string.IsNullOrWhiteSpace( value );
} ) );
step.Layout.Add( Text( "Detail", draft.Detail, value => draft.Detail = value ) );
}
// Mutate, then advance: advancing IS the refresh, so calling one as well tears the panel down twice.
void Answer( Action change, ArchWorkflowStep step ) {
change();
Stage.Restage();
step.Chose();
}
protected override string Diagnose() {
return string.Join( "\n",
$"type: {draft.Name} ({draft.Tier}), {(draft.Prefabbed ? "prefab" : "procedural")}",
$"module: {draft.Width:0.##} x {draft.Depth:0.##}, carcass {draft.Carcass:0.##}",
$"plinth: {draft.Plinth:0.##} recessed {draft.PlinthRecess:0.##}",
$"top: {draft.Counter:0.##} thick, oversail {draft.TopOversail:0.##} / {draft.TopEndOversail:0.##} / {draft.TopBackOversail:0.##}",
$"cornice: {draft.CorniceHeight:0.##} projecting {draft.CorniceProjection:0.##} past {draft.CorniceOversail:0.##}",
$"fronts: {string.Join( ", ", draft.Fittings.Select( fitting => $"{fitting.Mount}={ArchCabinetUi.Named( fitting )}" ) )}",
$"carcasses: {string.Join( ", ", draft.Carcasses.Select( carcass => $"{carcass.Slot}={carcass.Prefab}" ) )}",
$"finish: {(string.IsNullOrWhiteSpace( draft.MaterialGroup ) ? "as modelled" : draft.MaterialGroup)}" );
}
protected override void Apply() {
applied?.Invoke( ArchCabinetTypes.Copy( draft ) );
Close();
}
protected override void SaveTemplate() {
if ( string.IsNullOrWhiteSpace( draft.Name ) ) {
Log.Warning( "Architecture: the cabinet type needs a name before it can be saved." );
return;
}
if ( !ArchShelved.Save( ArchShelf.Cabinets, draft ) ) {
Log.Warning( $"Architecture: could not write the cabinet type '{draft.Name}'." );
return;
}
tool?.RegisterCabinetType( ArchCabinetTypes.Copy( draft ) );
applied?.Invoke( ArchCabinetTypes.Copy( draft ) );
Close();
}
}