Editor tool class for architecture editing in the level editor. Manages the plan, kit, archetypes and pillar types, handles placement drafts, commits/banking/undo, regeneration of generated geometry, cursor/grid/wall snapping, selection, deletion and various context menu actions; coordinates scene generation and housekeeping like pruning deleted scene objects.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// One plan, one kit, one undo funnel across every tab - a tab is only a choice of subtools and of what its overlay targets.
public abstract partial class ArchTool : EditorTool
{
const float SettleDelay = 0.25f;
public static ArchTool Active { get; private set; }
public ArchPlan Plan { get; private set; } = new();
public ArchKit Kit { get; private set; } = new();
public List<ArchArchetype> Archetypes { get; private set; } = new();
public List<ArchPillarType> PillarTypes { get; private set; } = new();
public int ActiveBuildingId { get; set; }
public int ActiveRoomId { get; set; }
public int Level { get; set; }
public string ActivePreset { get; set; } = "door";
public string ActiveWindow { get; set; } = "sash_4over4";
public ArchSelection Picked { get; set; }
// Bumped on every commit so read-only viewers (the Plan Layers dock) notice a plan edit.
public int Revision { get; private set; }
public bool HideRoofs { get; set; }
public bool HideWalls { get; set; }
public bool HideFloors { get; set; }
public bool HideFoundation { get; set; }
public bool HideTrim { get; set; }
public bool OnlyCurrentLevel { get; set; }
public float StoreyHeight
{
get
{
var building = ActiveBuilding();
if ( building is { StoreyHeight: > 1f } )
{
return building.StoreyHeight;
}
return EnsureKit().WallHeight + EnsureKit().FloorThickness;
}
}
public float LevelHeight => FloorOf( Level );
// The ceiling over the storey being edited, so a drag made looking up has a plane to land on. The active
// room answers where there is one, and the kit where there is not. Memoised per edit, because the cursor
// asks for it several times a frame and finding a recess in it walks the room's cuts.
public float Overhead()
{
if ( overheadTaken )
{
return overhead;
}
overheadTaken = true;
overhead = LevelHeight + EnsureKit().WallHeight - ArchFloorGen.CeilingDepth( EnsureKit() );
if ( ActiveRoom() is { } room && Plan?.OwnerOf( room ) is { } building )
{
ArchFootprint.Bounds( ArchFloorGen.Footprint( room ), out var min, out var max );
overhead = ArchFloorGen.Overhead( Plan, building, room, EnsureKit(), (min + max) * 0.5f );
}
return overhead;
}
public ArchGridService Grid { get; } = new();
ArchLayerTree projectedLayers;
// One projection per edit - the dock and the picker read the same tree Revise invalidates.
public ArchLayerTree LayerTree
{
get
{
var tree = projectedLayers ??= ArchLayerTree.Project( Plan );
// A target that no longer resolves (deleted, reverted) must not keep accepting children.
if ( InsertionTarget is { } target && tree.Find( target.ItemId ) is null )
{
InsertionTarget = null;
}
return tree;
}
}
// The parent a newly placed child will be filed under - separate from Picked by design.
public ArchLayerRef? InsertionTarget { get; set; }
public ArchLayerRef? SelectedLayer => Picked?.Item is { } item ? LayerTree.Find( item )?.Ref : null;
// Placements in flight - the tree shows them under their named parent until Finish or Cancel.
readonly List<ArchDraft> drafts = new();
public IReadOnlyList<ArchDraft> Drafts => drafts;
public ArchDraft BeginPlacement( ArchKind kind, ArchLayerRef? parent, object payload, string name = null, object owner = null )
{
var draft = new ArchDraft( kind, parent, name ?? $"{ArchKindsAsked.Label( kind )} (draft)", payload, owner );
drafts.Add( draft );
return draft;
}
// A draft belongs to the subtool that opened it, and only that subtool takes it off again. One left behind -
// by a tool torn down without its disable, or a shelf raised afresh over the top of it - has nobody to finish
// or cancel it, so the stack shows a placement in flight that no gesture can ever land.
void SweepDrafts() => drafts.RemoveAll( draft => draft.Owner is not null && !ReferenceEquals( draft.Owner, CurrentTool ) );
public void FinishPlacement( ArchDraft draft, int itemId )
{
draft.Finish( itemId );
drafts.Remove( draft );
FileWithItsGroup( draft, itemId );
Affect( itemId );
}
// A root layer drawn while a group is the target joins it, so the first house you place inside a
// housing group is inside its scope and everything the group already reaches can cut into it.
// Only root kinds: a wall belongs to its room, and moving its row into a folder would lose that.
void FileWithItsGroup( ArchDraft draft, int itemId )
{
if ( itemId == 0 || !ArchKindsAsked.RootsAtPlan( draft.Kind ) )
{
return;
}
if ( ArchLayerGroups.Holding( Plan, itemId ) is not null )
{
return;
}
var wanted = ArchLayerGroups.Find( Plan, draft.Parent?.ItemId ?? 0 )
?? ArchLayerGroups.Find( Plan, EditedGroupId );
ArchLayerGroups.Join( Plan, wanted, itemId );
}
public void CancelPlacement( ArchDraft draft )
{
draft.Cancel();
drafts.Remove( draft );
}
// The first disable writes the layer's record; the record then owns the enabled state and the
// next rebuild drops the layer and its effects from generation.
public void ToggleLayerEnabled( ArchLayerRef layer )
{
if ( LayerTree.Find( layer.ItemId ) is not { } node || node.Payload is null )
{
return;
}
var record = Plan.Layers.FirstOrDefault( entry => entry.ItemId == layer.ItemId );
if ( record is null )
{
record = new ArchLayerRecord
{
ItemId = layer.ItemId,
ParentId = layer.ParentId,
Kind = layer.Kind,
Stage = node.Stage,
};
Plan.Layers.Add( record );
}
record.Enabled = !record.Enabled;
Commit( record.Enabled ? $"Enable {node.Name}" : $"Disable {node.Name}" );
}
public ArchKit EnsureKit()
{
return Kit ??= new ArchKit();
}
// One floor-height answer for overlay, tools and stacked storeys; the plinth lives in GradeLift, not here.
public float FloorOf( int level ) => level * StoreyHeight + EnsureKit().GroundClearance;
string committed;
string pending;
RealTimeSince touched;
int standing;
Ray cursorRay;
ArchCursor cursorFound;
int cursorStamp;
ArchViewAxis cursorAxis;
float cursorDepth;
float cursorStanding;
float cursorReach;
bool cursorTaken;
// The surface a gesture is working, held for as long as the gesture lasts. Null is "wherever the ray lands".
public float? Standing { get; private set; }
// WHICH surface, not just how high: a snap on a deck may only reach that deck's own walls, or a parapet's
// end is yanked down onto the corner of a room two storeys under it.
public ArchRoofPart StandingDeck { get; private set; }
public void StandOn( float height, ArchRoofPart deck = null )
{
Standing = height;
StandingDeck = deck;
}
public void StepOff()
{
Standing = null;
StandingDeck = null;
}
// The grid rounds to the nearest rung, which is what pulls a cursor off the corner it was aimed at. A wall
// snap is the answer to that, so it stands beside the editor's grid rather than under it, and 0 takes a
// subgrid step off whatever spacing the viewport is drawing.
public bool SnapsToWalls { get; set; } = true;
public float WallSnapReach { get; set; }
public override void OnEnabled()
{
Active = this;
AllowGameObjectSelection = true;
Plan = ArchStorage.LoadPlan( Scene );
Kit = ArchStorage.LoadKit( Plan.KitName ) ?? new ArchKit();
Archetypes = ArchStorage.LoadArchetypes();
PillarTypes = ArchAsks.PillarTypes();
Revise();
committed = ArchStorage.Snapshot( Plan );
ArchDockPanel.Open();
ArchLayersDockPanel.Open();
ArchLayerInspectorPanel.Open();
}
// The engine rebuilds the bar on every tool or subtool change, so it holds no state.
public override Widget CreateToolbarWidget() => new ArchToolbar( this );
public override void OnDisabled()
{
Save();
if ( Active == this )
{
Active = null;
}
}
public override void OnUpdate()
{
// Any mesh press is an object pick; Select must hear Gizmo.Pressed to tell a grab from a pick.
AllowGameObjectSelection = CurrentTool is not ArchSelectSubtool;
SweepDrafts();
Settle();
SettlePreview();
SyncDeletions();
DrawPlan();
}
// A grid is scaffolding for placing; with Select up there is nothing to place, so it shows none.
public bool Placing => CurrentTool is ArchSubtool { Placing: true };
// A plan edit rebuilds the whole scene, so a value typed a character at a time lands once typing stops.
public void Touch( string action = "Architecture Edit" )
{
pending = action;
touched = 0f;
Revise();
}
void Settle()
{
if ( pending is null || touched < SettleDelay )
{
return;
}
var action = pending;
pending = null;
Commit( action );
}
// Plan is the source of truth: deleted geometry prunes its entry; no root means clearing geometry, not layout.
// Only the part COUNT is watched; the compare runs on the frame it drops.
public void SyncDeletions()
{
var root = ArchScene.FindRoot( Scene );
if ( !root.IsValid() )
{
return;
}
var counted = Parts( root );
var lost = counted < standing;
standing = counted;
if ( lost )
{
Prune( root );
}
}
static int Parts( GameObject root )
{
var count = 0;
foreach ( var building in root.Children )
{
count++;
foreach ( var node in building.Children )
{
count += 1 + node.Children.Count;
}
}
return count;
}
void Prune( GameObject root )
{
var alive = new HashSet<string>();
foreach ( var building in root.Children )
{
if ( building.IsDestroyed )
{
continue;
}
alive.Add( building.Name );
foreach ( var node in building.Children )
{
if ( node.IsDestroyed )
{
continue;
}
alive.Add( node.Name );
foreach ( var part in node.Children )
{
if ( !part.IsDestroyed )
{
alive.Add( part.Name );
}
}
}
}
// An empty entry has no object in the scene to go missing; Commit is what drops it.
var removed = Plan.Units.RemoveAll( unit => unit switch
{
ArchBuilding building => building.HasContent && !alive.Contains( ArchNames.Building( building ) ),
ArchRoadPart road => !alive.Contains( ArchNames.Road( road ) ),
_ => false
} );
// Stairs first and plan-wide: a flight opens the slabs and walls of every building it walks
// through, so what survives anywhere decides what the holes everywhere are still for.
foreach ( var room in Plan.AllRooms() )
{
removed += room.Stairs.RemoveAll( stair => !alive.Contains( ArchNames.Stair( stair ) ) );
// A porch hosts flights too, so the steps off its deck are pruned exactly as the room's own are.
foreach ( var porch in room.Porches )
{
removed += porch.Stairs.RemoveAll( stair => !alive.Contains( ArchNames.Stair( stair ) ) );
}
}
// A hole belongs to a flight, a walkway mouth, a cut, or the wall's own bay rhythm; any owner still
// standing keeps it open.
var climbing = Plan.AllRooms()
.SelectMany( room => room.Stairs.Concat( room.Porches.SelectMany( porch => porch.Stairs ) ) )
.Select( stair => stair.Id )
.ToHashSet();
var owners = climbing
.Concat( Plan.AllRooms().Where( room => room.Spans ).Select( room => room.Id ) )
.Concat( Plan.AllCuts().Select( cut => cut.Id ) )
.Concat( Plan.AllWalls().Select( wall => wall.Id ) )
.ToHashSet();
foreach ( var building in Plan.Buildings )
{
removed += building.Roofs.RemoveAll( roof => !alive.Contains( ArchNames.Roof( roof ) ) );
removed += building.Rooms.RemoveAll( room => room.HasContent && !alive.Contains( ArchNames.Room( room ) ) );
removed += building.Downpipes.RemoveAll( pipe => !alive.Contains( ArchNames.Downpipe( pipe ) ) );
removed += building.Pipes.RemoveAll( run => !alive.Contains( ArchNames.Pipe( run ) ) );
removed += building.Brackets.RemoveAll( bracket => !alive.Contains( ArchNames.Bracket( bracket ) ) );
removed += building.Fences.RemoveAll( fence => !alive.Contains( ArchNames.Fence( fence ) ) );
foreach ( var roof in building.Roofs )
{
removed += roof.Walls.RemoveAll( wall => !alive.Contains( ArchNames.Wall( wall ) ) );
}
foreach ( var room in building.Rooms )
{
removed += room.Walls.RemoveAll( wall => !alive.Contains( ArchNames.Wall( wall ) ) );
removed += room.Pillars.RemoveAll( pillar => !alive.Contains( ArchNames.Pillar( pillar ) ) );
removed += room.Trims.RemoveAll( trim => !alive.Contains( ArchNames.Trim( trim ) ) );
removed += room.Porches.RemoveAll( porch => !alive.Contains( ArchNames.Porch( porch ) ) );
foreach ( var porch in room.Porches )
{
removed += porch.Pillars.RemoveAll( pillar => !alive.Contains( ArchNames.Pillar( pillar ) ) );
removed += porch.Trims.RemoveAll( trim => !alive.Contains( ArchNames.Trim( trim ) ) );
}
removed += room.Approaches.RemoveAll( approach => !alive.Contains( ArchNames.Approach( approach ) ) );
}
// A stairwell exists only for a stair; deleting the flight has to take the shaft or the floor
// above keeps a dead hole. Plan-wide, because a flight drawn through a party wall opens the
// slabs of the building it arrives in as well as the one it was filed in.
removed += building.Cutouts.RemoveAll( cutout => cutout.OwnerId != 0 && !climbing.Contains( cutout.OwnerId ) );
}
foreach ( var wall in Plan.AllWalls() )
{
removed += wall.Openings.RemoveAll( opening => opening.OwnerId != 0 && !owners.Contains( opening.OwnerId ) );
}
if ( removed == 0 )
{
return;
}
Picked = null;
Commit( "Delete Architecture" );
}
public void Save()
{
ArchStorage.SavePlan( Scene, Plan );
}
// A hotload keeps this tool and its loaded kit alive, so disk edits land only once something re-reads the kit.
public ArchKit ReloadKit()
{
Kit = ArchStorage.LoadKit( Plan.KitName ) ?? new ArchKit();
ArchStyle.InvalidateCache();
Commit();
return Kit;
}
// Same trap as the kit: the live tool survives the hotload that saved the recipe.
public List<ArchArchetype> ReloadArchetypes()
{
Archetypes = ArchStorage.LoadArchetypes();
return Archetypes;
}
public ArchArchetype FindArchetype( string name )
{
return Archetypes.FirstOrDefault( archetype => string.Equals( archetype.Name, name, StringComparison.OrdinalIgnoreCase ) );
}
// A designed type must be listed the moment it is applied, whether or not it has reached disk.
public static void Register<T>( List<T> into, T entry, Func<T, string> named )
{
if ( into is null || entry is null || named( entry ) is not { Length: > 0 } key )
{
return;
}
into.RemoveAll( existing => string.Equals( named( existing ), key, StringComparison.OrdinalIgnoreCase ) );
into.Add( entry );
into.Sort( ( left, right ) => string.Compare( named( left ), named( right ), StringComparison.OrdinalIgnoreCase ) );
}
public void RegisterArchetype( ArchArchetype type ) => Register( Archetypes, type, entry => entry.Name );
public void RegisterWallPreset( ArchWallPreset preset ) => Register( Kit?.Walls, preset, entry => entry.Name );
public void RegisterOpeningPreset( ArchOpeningPreset preset ) => Register( Kit?.Openings, preset, entry => entry.Name );
public List<ArchPillarType> ReloadPillarTypes()
{
PillarTypes = ArchAsks.PillarTypes();
return PillarTypes;
}
public ArchPillarType FindPillarType( string name )
{
return PillarTypes.FirstOrDefault( type => string.Equals( type.Name, name, StringComparison.OrdinalIgnoreCase ) );
}
// A type naming no pillars offers every kind, so a building authored before pillars were data keeps its picker.
public List<ArchPillarType> OfferedPillars()
{
var wanted = FindArchetype( ActiveBuilding()?.Archetype )?.Pillars;
if ( wanted is null || wanted.Count == 0 )
{
return PillarTypes;
}
var offered = wanted
.Select( FindPillarType )
.Where( type => type is not null )
.ToList();
return offered.Count > 0 ? offered : PillarTypes;
}
public ArchPillarType DefaultPillar() => OfferedPillars().FirstOrDefault();
// The type's presets float to the front only - narrowing the list would strand presets the moment a map mixed two types.
public IEnumerable<ArchOpeningPreset> Offered( Func<ArchOpeningPreset, bool> wanted )
{
var matching = Kit.Openings.Where( wanted ).ToList();
var type = FindArchetype( ActiveBuilding()?.Archetype );
if ( type is null || type.Openings.Count == 0 )
{
return matching;
}
return matching
.OrderBy( preset => type.Openings.FindIndex( name => string.Equals( name, preset.Name, StringComparison.OrdinalIgnoreCase ) ) switch
{
< 0 => int.MaxValue,
var rank => rank
} )
.ToList();
}
// What Offered floats to the front, said as a yes or no so a browser can file it under Recommended.
public bool Recommends( ArchOpeningPreset preset )
{
var type = FindArchetype( ActiveBuilding()?.Archetype );
return type is not null
&& type.Openings.Any( name => string.Equals( name, preset.Name, StringComparison.OrdinalIgnoreCase ) );
}
// What the last build left standing, so the next one hands the engine only what moved. Needs no clearing when the
// plan changes: the key is the geometry's own, so a moved part reads as moved and a lost node reads as missing.
readonly ArchBuildCache built = new();
// Outside engine undo scope: Commit owns the only undo entry, at plan level.
// Revise here too, not only in Commit, to cover a handle drag that edits every frame.
public void Regenerate( ArchGenerationServices generation = null )
{
EnsureKit();
Revise();
// Re-baseline the deletion watch: a fresh build is exactly what the plan says, not a delete.
standing = 0;
previewPending = false;
Unbuilt = false;
// Measured, never assumed: what the next beat may cost is what the last one did, and only the build itself
// knows whether the gate let one scope through or a whole group.
RealTimeSince spent = 0f;
var root = ArchScene.Generate( Scene, Plan, Kit, generation, LayerTree, built );
previewCost = spent;
previewed = 0f;
if ( !root.IsValid() )
{
return;
}
ArchScene.LinkDoublePairs( root );
ArchFaceUvMappings.ApplyStored( this );
ApplyVisibility( root );
}
// The one way to ask for a cold build: throw away what we think is standing and emit the lot. The Rebuild
// button means this - it is what you press when the scene and the plan have stopped agreeing.
public void RebuildCold()
{
Disown();
Commit( "Rebuild Architecture" );
}
// Something has edited the built meshes out from under us - the cull deletes buried faces, the bake merges a
// room into one mesh. What is standing is no longer what this tool emitted, so the next rebuild has to emit all
// of it rather than trust a key and leave the polish in place. Both say a rebuild puts it back; this is why.
public void Disown() => built.Forget();
public ArchBuildSettlement LastBuild => built.Last;
// A handle drag edits the plan on every rung it crosses. The plan-side memos are still dropped every frame - the
// overlay reads them and it is a dictionary clear - but the geometry catches up on a beat, and the edit ghost
// carries the frames between.
const float PreviewInterval = 0.08f;
// The beat is only ever as fast as the build it asks for, and a build is NOT cheap just because the cache spares
// the engine: an edit inside a group re-runs every generator in it, which measures three quarters of a second on
// two houses while handing the engine nothing at all. Asked for twelve times a second, that is a drag running a
// second behind the mouse for as long as it lasts. So the beat is held to a share of what the last one actually
// COST - a small plan keeps the 80ms, a heavy one drags on the ghost and settles the moment it stops moving.
const float PreviewDuty = 3f;
const float PreviewCeiling = 1.5f;
public static float Beat( float cost ) => MathF.Min( PreviewCeiling, MathF.Max( PreviewInterval, cost * PreviewDuty ) );
RealTimeSince previewed;
bool previewPending;
float previewCost;
public void Preview()
{
Revise();
if ( previewed < Beat( previewCost ) )
{
previewPending = true;
return;
}
Regenerate();
}
// A drag that stops moving must still settle, or the geometry sits a rung behind wherever the mouse was let go.
void SettlePreview()
{
if ( previewPending && previewed >= Beat( previewCost ) )
{
Regenerate();
}
}
// The one funnel every edit goes through; the undo restores a plan snapshot and rebuilds from it.
public void Commit( string action = "Architecture Edit" ) => Commit( action, true );
// The plan written, saved and put on the undo stack with the SCENE left to catch up. Rebuilding is the
// expensive half of a commit by two orders of magnitude, and a drawing that writes straight through spends
// one on every stroke - so its edits are banked and a single Build pays for all of them at once.
public void Bank( string action = "Architecture Edit" ) => Commit( action, false );
// Edits are standing in the plan that the scene has not been shown. Cleared by the next build, whoever asks
// for it - a banked edit is not a private one, so anything that rebuilds settles it.
public bool Unbuilt { get; private set; }
public void Build()
{
Regenerate();
}
void Commit( string action, bool geometry )
{
EnsureKit();
// The placement that seeded a target has finished by now, so anything still holding nothing was never drawn.
Plan.DiscardEmptyTargets();
Revise();
// Every affector's effects are an effect it OWNS, so they are re-derived from where it stands
// NOW - dragging one re-thinks its join exactly as placing it did.
ArchAffectors.Resolve( Plan, Kit );
// Resolved once and handed to the rebuild, or Commit would normalize, snapshot, then normalize again.
var generation = new ArchGenerationServices( Plan, Kit, Scene ).ResolveConnections();
// What the edit reached: the stack flashes it, and isolate folds away the groups it did not.
if ( !ConsumeAffected() && SelectedLayer is { } edited )
{
Affect( edited.ItemId );
}
var before = committed;
var after = ArchStorage.Snapshot( Plan );
committed = after;
Revision++;
Save();
if ( geometry )
{
Regenerate( generation );
}
else
{
Unbuilt = true;
}
if ( before is null || before == after )
{
return;
}
SceneEditorSession.Active?.AddUndo( action, () => Rewind( before ), () => Rewind( after ) );
}
void Rewind( string snapshot )
{
var restored = ArchStorage.Restore( snapshot );
if ( restored is null )
{
return;
}
Plan = restored;
Picked = null;
Revise();
committed = ArchStorage.Snapshot( Plan );
Save();
Regenerate();
}
public ArchBuilding ActiveBuilding()
{
return Plan.FindBuilding( ActiveBuildingId ) ?? Plan.Buildings.FirstOrDefault();
}
public ArchRoom ActiveRoom()
{
var building = ActiveBuilding();
return building?.Rooms.FirstOrDefault( room => room.Id == ActiveRoomId ) ?? building?.Rooms.FirstOrDefault();
}
// The host room must be on the storey the fitting STANDS on: the seeded empty room would file it a storey down. Active room is only the tie-breaker.
public ArchRoom RoomOnLevel( Vector2 point ) => RoomOnLevel( Plan.AllRooms(), Level, LevelHeight, StoreyHeight, point, ActiveRoom() );
public static ArchRoom RoomOnLevel( IEnumerable<ArchRoom> rooms, int level, float levelHeight, float storeyHeight, Vector2 point, ArchRoom active )
{
// Floor and BaseHeight must agree, or the room hosts whatever it gets on the wrong floor plane.
var storey = rooms
.Where( room => room.Floor == level && MathF.Abs( room.BaseHeight - levelHeight ) < storeyHeight * 0.5f )
.ToList();
var inside = storey.FirstOrDefault( room => ArchFloorGen.Contains( ArchFloorGen.Footprint( room ), point ) );
if ( inside is not null )
{
return inside;
}
return active is not null && storey.Contains( active ) ? active : storey.FirstOrDefault();
}
// Outside every footprint a part belongs to the wall it stands against; the active room is a last resort.
public ArchRoom RoomAt( Vector2 point, out ArchBuilding building )
{
var room = Contained( point );
if ( room is null )
{
NearestWall( point, out room, out _, out _ );
}
room ??= ActiveRoom();
building = (room is null ? null : Plan.OwnerOf( room )) ?? ActiveBuilding();
return room;
}
// Not RoomOnLevel: that falls back; this asks whether the cursor is in a room at all.
public ArchRoom Contained( Vector2 point )
{
foreach ( var building in Plan.Buildings )
{
if ( Contained( building, point ) is { } room )
{
return room;
}
}
return null;
}
public ArchRoom Contained( ArchBuilding building, Vector2 point )
{
foreach ( var room in building.Rooms )
{
if ( room.Floor == Level && ArchFootprint.Contains( Outline( room ), point ) )
{
return room;
}
}
return null;
}
// Called only where a placement is about to need somewhere to file itself; Commit drops what it left empty.
public void EnsureTarget()
{
if ( Plan.Buildings.Count == 0 )
{
Plan.Units.Add( new ArchBuilding { Id = Plan.AllocateId(), Name = "Building" } );
}
var building = ActiveBuilding();
ActiveBuildingId = building.Id;
if ( building.Rooms.Count == 0 )
{
// Seed Floor AND BaseHeight - height alone put the ground-floor room a storey up in the air.
building.Rooms.Add( new ArchRoom { Id = Plan.AllocateId(), Name = "Room", Floor = Level, BaseHeight = LevelHeight } );
}
ActiveRoomId = ActiveRoom()?.Id ?? 0;
}
// Drawn at the top of the overlay, above the visibility and action rows every tab shares.
public virtual void BuildTargets( Layout header, Action refresh ) { }
// Ridge direction is decided once, on placement, so this turns it without the property sheet.
public override void BuildSceneContextMenu( Menu menu, Ray ray, SceneTraceResult? trace )
{
if ( GroundPoint( ray, out var point ) )
{
ArchContextActions.Load().Build( new ArchContextActionContext( this, menu, Level, point ) );
}
ApproachMenu( menu, ray );
if ( Under( ray ) is not { Style: RoofStyle.Gable or RoofStyle.Shed or RoofStyle.Sawtooth } roof )
{
return;
}
menu.AddOption( $"Turn {roof.Name} ridge", "rotate_90_degrees_cw", () =>
{
roof.RidgeAlongX = !roof.RidgeAlongX;
Commit( "Turn Ridge" );
} );
}
// The placement sidebar cannot reach a ramp that is already down.
void ApproachMenu( Menu menu, Ray ray )
{
if ( ApproachUnder( ray ) is not { } approach )
{
return;
}
if ( approach.Kind == ApproachKind.Ramp )
{
var kerbs = menu.AddOption( $"{approach.Name} kerbs", "align_horizontal_center",
() => { approach.Kerbs = !approach.Kerbs; Commit( "Ramp Kerbs" ); } );
kerbs.Checkable = true;
kerbs.Checked = approach.Kerbs;
}
else
{
var rails = menu.AddOption( $"{approach.Name} handrails", "fence",
() => { approach.Rails = !approach.Rails; Commit( "Approach Handrails" ); } );
rails.Checkable = true;
rails.Checked = approach.Rails;
}
menu.AddSeparator();
}
ArchApproachPart ApproachUnder( Ray ray )
{
return GroundPoint( ray, out var point ) ? ArchPick.ApproachUnder( Plan, Level, point, out _ ) : null;
}
ArchRoofPart Under( Ray ray )
{
return GroundPoint( ray, out var point ) ? ArchPick.RoofUnder( Plan, Level, point, out _ ) : null;
}
// Memoised against the ray, the terrain stamp (a flatten moves the ground) and the work plane.
public bool Cursor( out ArchCursor cursor )
{
var ray = Gizmo.CurrentRay;
var plane = ArchWorkPlane.For( this );
var standing = plane.Standing ?? float.MinValue;
// The reach is in the key for the same reason the pinned height is: change it and the same ray comes to
// rest somewhere else, so a memo blind to it answers with yesterday's snap until the mouse moves.
if ( cursorTaken && cursorStamp == ArchTerrain.Stamped && cursorAxis == plane.Axis && cursorDepth == plane.Depth
&& cursorStanding == standing && cursorReach == plane.WallReach
&& ray.Position == cursorRay.Position && ray.Forward == cursorRay.Forward )
{
cursor = cursorFound;
return cursorFound.Found;
}
cursorRay = ray;
cursorStamp = ArchTerrain.Stamped;
cursorAxis = plane.Axis;
cursorDepth = plane.Depth;
cursorStanding = standing;
cursorReach = plane.WallReach;
cursorTaken = true;
plane.Locate( Grid, Scene, ray, out cursorFound );
cursor = cursorFound;
return cursorFound.Found;
}
public bool GroundPoint( out Vector2 point )
{
var found = Cursor( out var cursor );
point = cursor.Plan;
return found;
}
public bool GroundPoint( Ray ray, out Vector2 point )
{
var found = ArchWorkPlane.For( this ).Locate( Grid, Scene, ray, out var cursor );
point = cursor.Plan;
return found;
}
public Vector2 Snap( Vector2 point )
{
return Grid.Base( point );
}
// Whether a wall already standing on the surface being worked takes this point ahead of the grid, and where it
// takes it. A handle and the stair drawing both ask here, or a flight walked onto a wall and the same flight
// dragged onto it come to rest in two different places - which is the rule ArchWorkPlane.Settle keeps for a
// point being placed, asked of a point being dragged.
public bool Walled( Vector2 point, out Vector2 at, int? storey = null, ArchRoofPart deck = null )
{
at = point;
if ( !SnapsToWalls )
{
return false;
}
var found = ArchWallSnap.Nearest( ArchWallSnap.On( Plan, storey ?? Level, deck ?? StandingDeck ), point,
ArchWallSnap.Reach( WallSnapReach ) );
at = found.Point;
return found.Took;
}
public Vector2 Settled( Vector2 point, int? storey = null, ArchRoofPart deck = null )
{
return Walled( point, out var at, storey, deck ) ? at : Grid.Base( point );
}
// Empty plan with standing geometry: clearing would destroy what no undo could restore, so refuse.
public void ClearPlan()
{
if ( !Plan.HasContent && Generated() )
{
Log.Warning( "Architecture: this scene's plan is empty but the scene still holds generated geometry, so clearing could"
+ " not be undone. Reopen the scene to load its plan, or delete the Generated Architecture object by hand." );
return;
}
Plan = new ArchPlan { KitName = Plan.KitName };
Picked = null;
ActiveBuildingId = 0;
ActiveRoomId = 0;
Commit( "Clear Architecture Plan" );
}
bool Generated()
{
var root = ArchScene.FindRoot( Scene );
return root.IsValid() && root.Components.GetAll<MeshComponent>( FindMode.EverythingInDescendants ).Any();
}
// One selection funnel for viewport clicks and the Plan Layers tree; both land in the same scope.
public void Select( ArchSelection picked )
{
Picked = picked;
(CurrentTool as ArchSubtool)?.Forget();
if ( picked?.Room is { } room )
{
Level = room.Floor;
ActiveBuildingId = picked.Building?.Id ?? 0;
ActiveRoomId = room.Id;
}
else if ( picked?.Item is ArchBuilding building )
{
ActiveBuildingId = building.Id;
}
(CurrentTool as ArchSubtool)?.RefreshSidebar();
}
// The whole GROUP is what a delete reaches, not the part: everything the deleted layer was reaching into
// has to be re-thought without it, so the scope it stood in is what gets dirtied - and named, or the
// commit that follows has no selection left to name and flashes whatever the last edit did.
public void DeleteSelected()
{
if ( Picked is null )
{
return;
}
var layer = SelectedLayer?.ItemId ?? 0;
var scope = ArchLayerGroups.Holding( Plan, layer )?.Id ?? layer;
Picked.RemoveFrom( Plan );
Picked = null;
Affect( scope );
Commit();
}
// An opening belongs to its wall's room; the active room alone left the Opening tools dead on the seeded empty room.
public ArchWall NearestWall( Vector2 point, out ArchRoom room, out float along, out float distance )
{
var storey = Plan.Buildings.SelectMany( building => building.Rooms ).Where( entry => entry.Floor == Level );
return NearestWall( storey, point, out room, out along, out distance );
}
public static ArchWall NearestWall( IEnumerable<ArchRoom> rooms, Vector2 point, out ArchRoom room, out float along, out float distance )
{
ArchWall best = null;
room = null;
along = 0f;
distance = float.MaxValue;
foreach ( var candidate in rooms )
{
var wall = NearestWall( candidate, point, out var at, out var gap );
if ( wall is null || gap >= distance )
{
continue;
}
best = wall;
room = candidate;
along = at;
distance = gap;
}
return best;
}
public static ArchWall NearestWall( ArchRoom room, Vector2 point, out float along, out float distance )
{
ArchWall best = null;
along = 0f;
distance = float.MaxValue;
if ( room is null )
{
return null;
}
foreach ( var wall in room.Walls )
{
var length = wall.Length;
if ( length < 0.5f )
{
continue;
}
var projected = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, length );
var closest = wall.PointAt( projected );
var gap = (point - closest).Length;
if ( gap >= distance )
{
continue;
}
distance = gap;
along = projected;
best = wall;
}
return best;
}
}