Editor/Data/ArchArchetypeBuild.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// Steps dispatch to the subtools' own authoring services; sections dress exactly as a hand-dragged one.
public static class ArchArchetypeBuild {
public sealed class Result {
public ArchBuilding Building { get; init; }
public List<string> Built { get; init; } = new();
public List<string> Skipped { get; init; } = new();
}
// Handed in, not looked up: disk-edited types beat the table; a generator cannot reach storage.
public static Result Place(
ArchPlan plan,
ArchKit kit,
ArchArchetype archetype,
ArchPlot plot,
int level,
float baseHeight,
IReadOnlyList<ArchPillarType> pillars = null ) {
if ( archetype is null || !archetype.HasLayout ) {
return null;
}
var building = new ArchBuilding {
Id = plan.AllocateId(),
Name = $"{archetype.Title}{plan.Buildings.Count + 1}",
Archetype = archetype.Name
};
var rooms = new Dictionary<string, ArchRoom>( StringComparer.OrdinalIgnoreCase );
var built = new List<string>();
var skipped = new List<string>();
ArchRoom latest = null;
foreach ( var step in archetype.Steps ) {
var made = Apply( plan, building, kit, archetype, step, plot, level, baseHeight, rooms, pillars, ref latest, skipped );
if ( made is not null ) {
built.Add( made );
}
}
// An empty building would win ActiveBuilding for every tool afterwards.
if ( building.Rooms.Count == 0 ) {
return null;
}
plan.Units.Add( building );
return new Result { Building = building, Built = built, Skipped = skipped };
}
static string Apply(
ArchPlan plan,
ArchBuilding building,
ArchKit kit,
ArchArchetype archetype,
ArchArchetypeStep step,
ArchPlot plot,
int level,
float baseHeight,
Dictionary<string, ArchRoom> rooms,
IReadOnlyList<ArchPillarType> pillars,
ref ArchRoom latest,
List<string> skipped ) {
plot.Resolve( step.Rect, out var min, out var max );
(min, max) = new ArchGridService().Rectangle( min, max );
var room = Target( step, rooms, latest );
if ( ArchArchetypeSteps.Load().For( step.Kind ) is { } builder ) {
return builder.Build( new ArchArchetypeStepContext( plan, building, kit, step, min, max, room, skipped ) );
}
switch ( step.Kind ) {
case ArchStepKind.Shell:
return Shell( plan, building, kit, archetype, step, min, max, level, baseHeight, rooms, ref latest, skipped );
case ArchStepKind.Wing:
return Wing( plan, building, kit, archetype, step, min, max, level, baseHeight, rooms, ref latest, skipped );
case ArchStepKind.Storey:
return Storey( plan, building, kit, archetype, step, rooms, ref latest, skipped );
case ArchStepKind.Extrude:
return Extrude( plan, building, kit, step, plot, room, skipped );
case ArchStepKind.Opening:
return Openings( plan, kit, step, plot, room, skipped );
case ArchStepKind.Pillars:
return Pillars( plan, kit, archetype, step, min, max, Target( step, rooms, latest ), pillars, skipped );
case ArchStepKind.Monitor:
return Monitor( plan, building, step, min, max, level, Target( step, rooms, latest ), skipped );
case ArchStepKind.Ladder:
return Ladder( plan, building, kit, step, plot, Target( step, rooms, latest ), skipped );
}
return null;
}
static string Shell(
ArchPlan plan,
ArchBuilding building,
ArchKit kit,
ArchArchetype archetype,
ArchArchetypeStep step,
Vector2 min,
Vector2 max,
int level,
float baseHeight,
Dictionary<string, ArchRoom> rooms,
ref ArchRoom latest,
List<string> skipped ) {
if ( building.Rooms.Count > 0 ) {
skipped.Add( "a second Shell step - a recipe describes one building, so the extra shell would stand inside it" );
return null;
}
var rules = Merged( archetype.Shell, step.Rules );
building.GuttersEnabled = rules.Gutters ?? true;
var section = ArchBuild.Shell(
plan, building, kit, level, baseHeight, min, max,
rules.Floor ?? true, rules.Roof, rules.Gutters ?? true, rules.Ridge );
if ( section is null ) {
skipped.Add( "a Shell step fully inside occupied grid space" );
return null;
}
Dress( section, rules, step, kit );
Register( step, section.Room, rooms, ref latest );
return $"Shell {section.Room.Name}";
}
static string Wing(
ArchPlan plan,
ArchBuilding building,
ArchKit kit,
ArchArchetype archetype,
ArchArchetypeStep step,
Vector2 min,
Vector2 max,
int level,
float baseHeight,
Dictionary<string, ArchRoom> rooms,
ref ArchRoom latest,
List<string> skipped ) {
if ( building.Rooms.Count == 0 ) {
skipped.Add( "a Wing step before any Shell - a wing has nothing to abut" );
return null;
}
var rules = Merged( archetype.Wing, step.Rules );
var section = ArchBuild.Extend(
plan, building, kit, level, baseHeight, min, max,
step.WingRoof, step.EaveDrop, rules.Floor ?? true, rules.Gutters ?? true, rules.Ridge );
if ( section is null ) {
skipped.Add( "a Wing step with no empty grid space beside its host" );
return null;
}
Dress( section, rules, step, kit );
Register( step, section.Room, rooms, ref latest );
return $"Wing {section.Room.Name}";
}
// The room below is the footprint, not the step's rectangle - an upper storey stands on what is under it.
static string Storey(
ArchPlan plan,
ArchBuilding building,
ArchKit kit,
ArchArchetype archetype,
ArchArchetypeStep step,
Dictionary<string, ArchRoom> rooms,
ref ArchRoom latest,
List<string> skipped ) {
if ( latest is null ) {
skipped.Add( "a Storey step before any Shell - nothing to stack onto" );
return null;
}
var rules = Merged( archetype.Shell, step.Rules );
var level = latest.Floor + 1;
ArchFootprint.Bounds( ArchFloorGen.Footprint( latest ), out var min, out var max );
var section = ArchBuild.Storey(
plan, building, kit, level, ArchBuild.FloorOf( building, kit, level ), min, max,
rules.Floor ?? true, rules.Roof, rules.Gutters ?? true, rules.Ridge );
if ( section is null ) {
skipped.Add( $"a Storey step over {latest.Name} with no footprint to stand on" );
return null;
}
Dress( section, rules, step, kit );
Register( step, section.Room, rooms, ref latest );
return $"Storey {section.Room.Name}";
}
// The plot rectangle UNSNAPPED: a zone is authored as a thin strip along one elevation, and the base grid would
// move or collapse it. The building is handed in whole, so the zone is filed straight onto it.
static string Extrude( ArchPlan plan, ArchBuilding building, ArchKit kit, ArchArchetypeStep step, ArchPlot plot, ArchRoom room, List<string> skipped ) {
if ( room is null ) {
skipped.Add( "an Extrude step with no storey to stand it on" );
return null;
}
if ( step.Extrude == ArchExtrudeKind.None ) {
skipped.Add( "an Extrude step with no kind" );
return null;
}
plot.Resolve( step.Rect, out var min, out var max );
var loop = ArchFootprint.Rect( min, max );
var seat = room.BaseHeight + step.SillHeight;
var head = step.Height > 1f ? room.BaseHeight + step.Height : seat + ArchBuild.StoreyHeight( building, kit );
if ( head - seat < 1f ) {
skipped.Add( $"an Extrude step over {room.Name} - its head stands under its foot" );
return null;
}
var zone = new ArchCutPart {
Id = plan.AllocateId(),
Name = $"Extrude{building.Cuts.Count + 1}",
Level = room.Floor,
Profile = CutProfile.Poly,
Affects = ArchExtrude.Targets( step.Extrude ),
Extrude = step.Extrude,
ExtrudeDepth = MathF.Max( ArchExtrude.LeastDepth, step.Proud ),
ExtrudeCourse = MathF.Max( 0f, step.Course ),
ExtrudeJoint = MathF.Max( 0f, step.Joint ),
ExtrudePillarType = step.Pillar
};
zone.Segments.Add( new ArchCutSegment {
Start = loop[0],
End = loop[2],
BaseHeight = seat,
TopHeight = head,
Loop = loop
} );
building.Cuts.Add( zone );
return $"{step.Extrude} zone over {room.Name}";
}
static void Dress( ArchSection section, ArchSectionRules rules, ArchArchetypeStep step, ArchKit kit ) {
ArchArchetypeRules.Apply( section, rules, kit );
if ( section.Roof is not null && step.SawtoothBays > 0 ) {
section.Roof.SawtoothBays = step.SawtoothBays;
}
}
// A step that says nothing inherits the type - keeps a recipe short.
static ArchSectionRules Merged( ArchSectionRules type, ArchSectionRules step ) {
var merged = new ArchSectionRules {
WallHeight = step.WallHeight > 1f ? step.WallHeight : type.WallHeight,
Roof = step.Roof ?? type.Roof,
Ridge = step.Ridge != RidgeRun.Auto ? step.Ridge : type.Ridge,
RoofPitch = step.RoofPitch > 0.5f ? step.RoofPitch : type.RoofPitch,
Overhang = step.Overhang > 0.01f ? step.Overhang : type.Overhang,
FrameSpacing = step.FrameSpacing > 4f ? step.FrameSpacing : type.FrameSpacing,
Floor = step.Floor ?? type.Floor,
Foundation = step.Foundation ?? type.Foundation,
Ceiling = step.Ceiling ?? type.Ceiling,
Gutters = step.Gutters ?? type.Gutters,
Fascia = step.Fascia ?? type.Fascia,
Soffit = step.Soffit ?? type.Soffit,
Frame = step.Frame ?? type.Frame,
FloorBoards = step.FloorBoards || type.FloorBoards,
FloorBoardYaw = step.FloorBoardYaw != 0f ? step.FloorBoardYaw : type.FloorBoardYaw,
// Dropped here for a long while, so a type's own silhouette and cladding reached a hand-dragged
// section and never a recipe-built one: the office wing came out with no parapet and bare walls.
Parapet = step.Parapet ?? type.Parapet,
ParapetHeight = step.ParapetHeight > 0.5f ? step.ParapetHeight : type.ParapetHeight,
Guardrail = step.Guardrail ?? type.Guardrail,
GuardHeight = step.GuardHeight > 0.5f ? step.GuardHeight : type.GuardHeight,
Wall = step.Wall ?? type.Wall
};
ArchArchetypeRules.Skin( merged.Palette, type.Palette );
ArchArchetypeRules.Skin( merged.Palette, step.Palette );
return merged;
}
static string Openings( ArchPlan plan, ArchKit kit, ArchArchetypeStep step, ArchPlot plot, ArchRoom room, List<string> skipped ) {
if ( room is null ) {
skipped.Add( $"an Opening step for '{step.Preset}' with no room to put it in" );
return null;
}
var wall = WallOn( room, plot.Resolve( step.Side ) );
if ( wall is null ) {
skipped.Add( $"an Opening step for '{step.Preset}' - {room.Name} has no wall on its {step.Side} side" );
return null;
}
// Exact match, not FindOpening's fallback - a missing preset would quietly place a different door.
var definition = kit.Openings.FirstOrDefault( preset => string.Equals( preset.Name, step.Preset, StringComparison.OrdinalIgnoreCase ) );
if ( definition is null ) {
skipped.Add( $"an Opening step - the kit has no preset named '{step.Preset}'" );
return null;
}
var width = step.Width > 1f ? step.Width : definition.Width;
var height = step.Height > 1f ? step.Height : definition.Height;
var count = Math.Max( 1, step.Count );
// More than one is a rhythm, and a wall owns its own rhythm. Spacing them here as well would be a
// second answer to where a unit goes, free to drift from the one the drag and the affector share.
if ( count > 1 ) {
wall.OpeningRun = new ArchOpeningRun {
Preset = definition.Name,
Count = count,
Width = step.Width,
Height = step.Height,
SillHeight = step.SillHeight
};
return $"a rhythm of {count} x {definition.Name} on {room.Name}'s {step.Side} wall";
}
var half = width * 0.5f;
var limit = MathF.Max( half, wall.Length - half );
var unit = definition.Spawn( plan.AllocateId(), Math.Clamp( wall.Length * Math.Clamp( step.Along, 0f, 1f ), half, limit ) );
unit.Width = width;
unit.Height = height;
unit.SillHeight = step.SillHeight > 0.01f ? step.SillHeight : unit.SillHeight;
unit.Leaf = step.Leaf ?? unit.Leaf;
unit.Cased = step.Cased ?? unit.Cased;
unit.Glazed = step.Glazed ?? unit.Glazed;
unit.StartOpen = step.StartOpen ?? unit.StartOpen;
wall.Openings.Add( unit );
return $"{definition.Name} at {width:0}x{height:0} in {room.Name}";
}
// The step's kind, else the type's own first - a recipe should not restate its own column.
static string Pillars(
ArchPlan plan,
ArchKit kit,
ArchArchetype archetype,
ArchArchetypeStep step,
Vector2 min,
Vector2 max,
ArchRoom room,
IReadOnlyList<ArchPillarType> pillars,
List<string> skipped ) {
if ( room is null ) {
skipped.Add( "a Pillars step with no room to stand them in" );
return null;
}
var kind = Kind( archetype, step, pillars );
if ( kind is null ) {
skipped.Add( "a Pillars step with no pillar type to stand" );
return null;
}
var reach = max - min;
var pitch = new Vector2(
step.Spacing.x > 1f ? step.Spacing.x : kind.Column.Spacing.x,
step.Spacing.y > 1f ? step.Spacing.y : kind.Column.Spacing.y );
var grid = new ArchGridService();
var alongX = grid.DivideAtMost( reach.x, MathF.Max( 32f, pitch.x ) );
var alongY = grid.DivideAtMost( reach.y, MathF.Max( 32f, pitch.y ) );
var part = kind.Standing( min, room.BaseHeight, 0f );
part.Id = plan.AllocateId();
part.Name = $"Pillars{room.Pillars.Count + 1}";
part.Placement = PillarPlacement.Grid;
part.CountX = alongX.Count + 1;
part.CountY = alongY.Count + 1;
part.Spacing = new Vector2( alongX.Step, alongY.Step );
if ( step.Span != PillarSpan.None ) {
part.Span = step.Span;
part.SpanAlongX = true;
part.SpanAlongY = false;
}
room.Pillars.Add( part );
return $"{kind.Title} in {room.Name}";
}
// Through ArchRoofLight, so a recipe's monitor is clipped to the patches it covers like a dragged one.
// The building is handed in whole: it is not filed in the plan until every step has run.
static string Monitor( ArchPlan plan, ArchBuilding building, ArchArchetypeStep step, Vector2 min, Vector2 max, int level, ArchRoom room, List<string> skipped ) {
if ( room is null ) {
skipped.Add( "a Monitor step with no room to stand it over" );
return null;
}
var roof = ArchRoofLight.Under( new[] { building }, level, (min + max) * 0.5f );
if ( roof is null ) {
skipped.Add( $"a Monitor step over {room.Name} - no Flat, Shed or Gable deck stands over the middle of that rectangle" );
return null;
}
var draft = new ArchRoofLightPart {
Form = RoofLightForm.Monitor,
Curb = step.Height > 1f ? step.Height : 40f,
CurbWidth = step.Width > 1f ? step.Width : 10f,
Glazed = step.Glazed ?? true,
PaneSpan = step.PaneSpan
};
if ( ArchRoofLight.Add( plan, roof, min, max, draft ) is not { } light ) {
skipped.Add( $"a Monitor step on {roof.Name} - that rectangle is under {ArchRoofLight.MinSize} inches on a side" );
return null;
}
return $"Monitor {light.Name} on {roof.Name}";
}
// Through the ladder role, so a recipe's ladder finds its grade and its head over the parapet the same way
// a clicked one does. Stood off the face, because the side clicked is what decides which way it faces.
static string Ladder( ArchPlan plan, ArchBuilding building, ArchKit kit, ArchArchetypeStep step, ArchPlot plot, ArchRoom room, List<string> skipped ) {
if ( room is null ) {
skipped.Add( "a Ladder step with no room to climb" );
return null;
}
var side = plot.Resolve( step.Side );
var wall = WallOn( room, side );
if ( wall is null ) {
skipped.Add( $"a Ladder step - {room.Name} has no wall on its {step.Side} side" );
return null;
}
var point = wall.PointAt( wall.Length * Math.Clamp( step.Along, 0f, 1f ) ) + Outward( side ) * (kit.WallThickness + 6f);
if ( ArchLadders.Place( new[] { building }, plan, kit, point, new ArchLadderPart(), out var host ) is not { } ladder ) {
skipped.Add( $"a Ladder step on {room.Name}'s {step.Side} wall - nothing standing there to hang it on" );
return null;
}
host.Ladders.Add( ladder );
return $"Ladder on {room.Name}";
}
static Vector2 Outward( ArchSide side ) {
return side switch {
ArchSide.MinX => new Vector2( -1f, 0f ),
ArchSide.MaxX => new Vector2( 1f, 0f ),
ArchSide.MinY => new Vector2( 0f, -1f ),
_ => new Vector2( 0f, 1f )
};
}
static ArchPillarType Kind( ArchArchetype archetype, ArchArchetypeStep step, IReadOnlyList<ArchPillarType> pillars ) {
if ( pillars is null || pillars.Count == 0 ) {
return null;
}
ArchPillarType Named( string name ) => string.IsNullOrWhiteSpace( name )
? null
: pillars.FirstOrDefault( type => string.Equals( type.Name, name, StringComparison.OrdinalIgnoreCase ) );
return Named( step.Pillar )
?? archetype.Pillars.Select( Named ).FirstOrDefault( type => type is not null )
?? pillars[0];
}
// By where it lies, not index - the party wall is breached out of the list.
static ArchWall WallOn( ArchRoom room, ArchSide side ) {
var across = side is ArchSide.MinX or ArchSide.MaxX;
var furthest = side is ArchSide.MaxX or ArchSide.MaxY;
ArchWall best = null;
var edge = furthest ? float.MinValue : float.MaxValue;
foreach ( var wall in room.Walls ) {
var direction = wall.Direction;
if ( across ? MathF.Abs( direction.x ) > 0.05f : MathF.Abs( direction.y ) > 0.05f ) {
continue;
}
var midpoint = (wall.Start + wall.End) * 0.5f;
var measured = across ? midpoint.x : midpoint.y;
if ( furthest ? measured <= edge : measured >= edge ) {
continue;
}
edge = measured;
best = wall;
}
return best;
}
static ArchRoom Target( ArchArchetypeStep step, IReadOnlyDictionary<string, ArchRoom> rooms, ArchRoom latest ) {
if ( string.IsNullOrWhiteSpace( step.Target ) ) {
return latest;
}
return rooms.TryGetValue( step.Target, out var found ) ? found : latest;
}
static void Register( ArchArchetypeStep step, ArchRoom room, IDictionary<string, ArchRoom> rooms, ref ArchRoom latest ) {
latest = room;
if ( !string.IsNullOrWhiteSpace( step.Name ) ) {
rooms[step.Name] = room;
}
}
}