Editor-side architecture code for boolean placement tools. Defines enums and data classes for boolean operations, computes where a drag stands (host, band, reach), sketches drag geometry, and places or sinks scene parts (slabs, pillars, beams, cuts) into plan structures.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// What the shape DOES where it stands. The same drag stands a plinth, sinks a shaft, or lays a block on
// a wall face - which is why these were never three tools, only three answers to one question.
public enum ArchBoolOp
{
Add,
Subtract,
Damage,
Fixture,
Detail
}
// What a solid IS. The gesture is one drag either way; the kind says which authored part the drag becomes,
// and every one of them is a part the tool already had - a pillar here is the same ArchPillarPart the
// Pillars shelf places, dressed by the same authored types.
public enum ArchBoolAdd
{
Slab,
// The same slab with a raked deck, which is all a ramp is - so it inherits the plinth's sides, its carving
// and its handles rather than standing up a part of its own.
Ramp,
Pillar,
Beam,
Slats
}
// The drafts a drag is authored from, in one object: the subtool, the MCP tools and a test all place
// through the same call, so a kind can never be wired up in one of them and missed in another.
public sealed class ArchBoolPlacement
{
public ArchBoolOp Op { get; init; }
public ArchBoolAdd Kind { get; init; }
public ArchBoolReach Reach { get; init; }
public ArchRoom Room { get; init; }
public ArchPlatformPart Platform { get; init; }
public ArchCutPart Cut { get; init; }
public ArchPillarPart Pillar { get; init; }
public ArchBeamPart Beam { get; init; }
}
// How far a subtraction reaches. Through is the shaft: it starts under the plane it was drawn on and runs
// out past everything standing over it. A recess stays INSIDE one body - the slab under your feet, or the
// ceiling body hanging over the storey - so what is left of that body keeps standing over or under it.
public enum ArchBoolReach
{
// The default, and the one that reads as "it does what I pointed at": the surface the drag landed on
// decides. Dragged on the floor it works the floor, dragged looking up at the ceiling it works that.
Dragged,
Through,
IntoFloor,
IntoCeiling,
IntoWall
}
// One resolve, shared by the ghost, the commit and the handles - a drag preview that derived its own
// loop would show a circle the rebuild rounded differently.
public sealed class ArchBoolDrag
{
public ArchBoolForm Form { get; init; }
public int Segments { get; init; } = ArchFootprint.LeastSegments;
public Vector2 Min { get; init; }
public Vector2 Max { get; init; }
public float Bottom { get; init; }
public float Top { get; init; }
// The direction the gesture ran, kept because the bounds throw the sign away - a raked top has to know
// which end of the box the author started at.
public float Climb { get; init; }
public bool Stands => Max.x - Min.x >= ArchBool.MinSize && Max.y - Min.y >= ArchBool.MinSize;
public float Rise => MathF.Max( 1f, Top - Bottom );
public List<Vector2> Loop()
{
return Form == ArchBoolForm.Circle
? ArchFootprint.Ellipse( Min, Max, Segments )
: ArchFootprint.Rect( Min, Max );
}
}
// What a drag stands on. A building holds solids and shafts; a road holds the cuts that open its own strips and
// the bores driven along it. One answer, so the ghost, the commit and the MCP tools cannot disagree about where
// a shape was filed - and out on a street there is no building for a bore to be filed under.
public sealed class ArchBoolHost
{
public ArchBuilding Building { get; init; }
public ArchRoadPart Road { get; init; }
public int Id => Road?.Id ?? Building?.Id ?? 0;
public bool Stands => Building is not null || Road is not null;
public List<ArchCutPart> Cuts => Road?.Cuts ?? Building?.Cuts;
// The storey a part filed here stands on. A road has none, so a cut on one is the ground's whatever level the
// author was working at - stamped with a storey it never had, the strips read it as standing over them and came
// out whole.
public int Storey( int level ) => Road is null ? level : 0;
}
public static class ArchBool
{
public const float MinSize = ArchPlatform.MinSize;
public static ArchBoolHost Host( ArchPlan plan, ArchBuilding preferred, IReadOnlyList<Vector2> outline )
{
if ( plan is null || outline is not { Count: >= 3 } )
{
return new ArchBoolHost { Building = preferred };
}
return Host( plan, preferred, outline.Aggregate( Vector2.Zero, ( total, point ) => total + point ) / outline.Count );
}
// Asked by the point rather than the loop, because the band a drag stands on is decided BEFORE its loop
// exists: a cut on the street is measured off the street, and the street is whatever the centre lands on.
public static ArchBoolHost Host( ArchPlan plan, ArchBuilding preferred, Vector2 centre )
{
if ( plan is null )
{
return new ArchBoolHost { Building = preferred };
}
var standing = plan.Buildings
.Where( building => building.Rooms.Any( room => room.HasFootprint && ArchFootprint.Contains( ArchFloorGen.Footprint( room ), centre ) )
|| building.Platforms.Any( platform => ArchFootprint.Contains( platform.Outline(), centre ) ) )
.ToList();
if ( standing.Contains( preferred ) )
{
return new ArchBoolHost { Building = preferred };
}
if ( standing.FirstOrDefault() is { } found )
{
return new ArchBoolHost { Building = found };
}
// In no room, so the street under the drag holds it. A building only wins where it actually contains the
// centre, or a road running through a yard would steal a cut aimed at the slab.
if ( Carrying( plan, centre ) is { } road )
{
return new ArchBoolHost { Road = road };
}
return new ArchBoolHost { Building = preferred ?? plan.Buildings.FirstOrDefault() };
}
// The nearest road whose own section reaches the point, widened by whatever bore stands round it - measured
// against the carriageway alone, a drag aimed at the lining lands outside every road.
static ArchRoadPart Carrying( ArchPlan plan, Vector2 point )
{
ArchRoadPart carrying = null;
var nearest = float.MaxValue;
foreach ( var road in plan.Roads() )
{
if ( !road.Curve().Nearest( point, out _, out var gap ) || gap >= nearest || gap > road.Reach() + Bored( road ) )
{
continue;
}
nearest = gap;
carrying = road;
}
return carrying;
}
static float Bored( ArchRoadPart road )
{
var reach = 0f;
foreach ( var tunnel in road.Tunnels )
{
reach = MathF.Max( reach, MathF.Max( 0f, tunnel.Clearance ) + MathF.Max( 2f, tunnel.Lining ) );
}
return reach;
}
// The bounds snap, the vertices are read off them. Snapping each corner of a circle instead would
// pull a round shape into a lumpy one, and the author never typed those corners.
//
// The BAND is the operation's, which is why the op is asked for here rather than left to each caller:
// a solid stands up from the plane it was dragged on, but a cut has to start UNDER it, because that
// plane is the top of the slab it is meant to take out. Starting level with it opened every storey
// above and left the floor you were looking at whole.
public static ArchBoolDrag Sketch(
ArchGridService grid,
ArchKit kit,
ArchBoolOp op,
Vector2 from,
Vector2 to,
float plane,
float reach,
ArchBoolForm form,
int segments,
ArchBoolReach into = ArchBoolReach.Through,
float depth = 0f,
float surface = 0f )
{
var start = grid.Base( from );
var finish = grid.Base( to );
var (bottom, top) = Band( kit, op, into, plane, reach, depth, surface );
return new ArchBoolDrag
{
Form = form,
Segments = Math.Max( ArchFootprint.LeastSegments, segments ),
Min = new Vector2( MathF.Min( start.x, finish.x ), MathF.Min( start.y, finish.y ) ),
Max = new Vector2( MathF.Max( start.x, finish.x ), MathF.Max( start.y, finish.y ) ),
Bottom = bottom,
Top = top,
Climb = ArchRamp.Climbed( from, to )
};
}
// A recess is measured off the SURFACE it is taken out of and bites a little past it, so the mouth is
// never a coplanar pair with the face it opens in; the depth then runs INTO the body, which is up for a
// ceiling and down for a floor. Reaching for the far side of the body instead is what turns a coffer
// into a hole.
static (float Bottom, float Top) Band( ArchKit kit, ArchBoolOp op, ArchBoolReach into, float plane, float reach, float depth, float surface )
{
var bite = ArchContact.Bite( kit );
var bitten = MathF.Max( 1f, depth );
if ( op is not (ArchBoolOp.Subtract or ArchBoolOp.Damage) )
{
return (plane, plane + MathF.Max( 4f, reach ));
}
return into switch
{
ArchBoolReach.IntoCeiling => (surface - bite, surface + bitten),
ArchBoolReach.IntoFloor => (surface - bitten, surface + bite),
ArchBoolReach.IntoWall => (surface - bite, surface + bitten),
// Through starts a slab UNDER the plane it was drawn on, because that plane is the top of the slab
// it is meant to take out.
_ => (plane - kit.FloorThickness, plane + MathF.Max( 4f, reach ))
};
}
// What the author pointed at, resolved ONCE: the picker's own answer where they made one, else the face
// the cursor came to rest on. Every band and every Affects mask downstream reads this and not the picker.
public static ArchBoolReach Aimed( ArchBoolReach picked, ArchCursorFace face, bool wall = false )
{
if ( picked != ArchBoolReach.Dragged )
{
return picked;
}
if ( wall )
{
return ArchBoolReach.IntoWall;
}
return face == ArchCursorFace.Ceiling ? ArchBoolReach.IntoCeiling : ArchBoolReach.Through;
}
// A recess belongs to the one body it was aimed at: left on All it takes the wall feet, the trims and
// whatever platform stands in the same band with it.
public static ArchCutAffects Aimed( ArchBoolReach into, ArchCutAffects authored ) => into switch
{
ArchBoolReach.IntoCeiling => ArchCutAffects.Ceilings | ArchCutAffects.Roofs,
ArchBoolReach.IntoFloor => ArchCutAffects.Floors,
ArchBoolReach.IntoWall => ArchCutAffects.Walls | ArchCutAffects.Trims | ArchCutAffects.Windows,
_ => authored
};
// The other half of the band rule: a cut starts a slab BELOW the plane it was drawn on, and it has to
// finish above the last thing standing over that plane, or the roof sits on nothing where the hole is.
// Stopping at the plate is why a shaft opened every floor and left the roof whole.
public static float Through( ArchPlan plan, ArchKit kit, int level, Vector2 min, Vector2 max, float plane )
{
var head = plane + kit.WallHeight;
if ( plan is null )
{
return head - plane;
}
var footprint = ArchFootprint.Rect( min, max );
var bite = ArchContact.Bite( kit );
foreach ( var building in plan.Buildings )
{
foreach ( var room in building.Rooms.Where( room => room.Floor >= level && room.HasFootprint ) )
{
if ( ArchFootprint.Overlaps( ArchFloorGen.Footprint( room ), footprint ) )
{
head = MathF.Max( head, room.BaseHeight + ArchFloorGen.WallHeight( room, kit ) + bite );
}
}
}
// A roof or a bore stands far higher than a storey, and either may be absent entirely, so the drag asks
// rather than measures.
return ArchAsks.Headroom( plan, kit, level, footprint, head ) - plane;
}
// Add stands the solid; Subtract sinks the hole. Detail is not here because it is not a footprint on
// the ground - it lands on a wall FACE, and its host is the wall the box crossed.
public static object Place( ArchPlan plan, ArchBoolHost host, int level, ArchBoolDrag drag, ArchBoolPlacement placing )
{
if ( !drag.Stands || host is not { Stands: true } )
{
return null;
}
if ( placing.Op != ArchBoolOp.Add )
{
return Sink( plan, host, level, drag, placing );
}
// A slab, a column and a beam are all filed under a building's storey, and a road has none - it carries
// cuts only.
if ( host.Building is null )
{
return null;
}
return placing.Kind switch
{
ArchBoolAdd.Slab or ArchBoolAdd.Ramp
=> ArchPlatform.Add( plan, host.Building, level, drag.Bottom, drag.Top, drag.Loop(), Raked( placing, drag ) ),
ArchBoolAdd.Pillar => Column( plan, drag, placing ),
_ => Timber( plan, drag, placing )
};
}
// The KIND says whether the deck rakes and the DRAG says which way it climbs, stamped on a COPY of the seed.
// A placement never writes to the draft it was handed: the subtool holds one for the whole session, so a
// gesture that stamped itself there carried its answer into the next one - which is why a slab dragged after
// a ramp arrived sloped, and why clearing the flag again was a patch rather than a fix.
static ArchPlatformPart Raked( ArchBoolPlacement placing, ArchBoolDrag drag )
{
var draft = placing.Platform.Dressing();
draft.Ramp = placing.Kind == ArchBoolAdd.Ramp;
if ( draft.Ramp )
{
draft.RampYaw = drag.Climb;
}
return draft;
}
// The drag IS the section: origin, width and depth come off the bounds, the sides off the form, and
// everything that dresses a column - footing, plinth, capital, span - comes off the authored type the
// draft was adopted from. Nothing about a pillar is re-invented here.
static ArchPillarPart Column( ArchPlan plan, ArchBoolDrag drag, ArchBoolPlacement placing )
{
if ( placing.Room is not { } room )
{
return null;
}
var part = placing.Pillar.Section();
part.Id = plan.AllocateId();
part.Name = $"Pillar{room.Pillars.Count + 1}";
part.Placement = PillarPlacement.Freestanding;
part.Origin = (drag.Min + drag.Max) * 0.5f;
part.BaseHeight = drag.Bottom;
part.Height = drag.Rise;
part.Width = drag.Max.x - drag.Min.x;
part.Depth = drag.Max.y - drag.Min.y;
part.Sides = drag.Form == ArchBoolForm.Circle ? drag.Segments : 0;
room.Pillars.Add( part );
return part;
}
static ArchBeamPart Timber( ArchPlan plan, ArchBoolDrag drag, ArchBoolPlacement placing )
{
if ( placing.Room is not { } room )
{
return null;
}
var beam = Hung( drag, placing );
beam.Id = plan.AllocateId();
beam.Name = $"{(beam.Members == BeamMembers.Field ? "Slats" : "Beam")}{room.Beams.Count + 1}";
beam.Level = room.Floor;
room.Beams.Add( beam );
return beam;
}
// One resolution the ghost and the placement both read, so the lane count a preview reports is the lane
// count the generator lays.
public static ArchBeamPart Hung( ArchBoolDrag drag, ArchBoolPlacement placing )
{
var draft = placing.Beam;
var beam = new ArchBeamPart
{
Members = placing.Kind == ArchBoolAdd.Slats ? BeamMembers.Field : BeamMembers.One,
TopHeight = drag.Top,
Drop = drag.Rise,
Type = draft.Type,
Sides = draft.Sides,
MemberWidth = draft.MemberWidth,
MemberGap = draft.MemberGap,
Yaw = draft.Yaw,
Level = placing.Room?.Floor ?? 0
};
beam.Reshape( drag.Loop() );
return beam;
}
// The leg carries the loop itself, so one shaft is one leg whatever its shape - Poly is the profile
// that says "the footprint IS the authored loop" rather than a run and a width.
static ArchCutPart Sink( ArchPlan plan, ArchBoolHost host, int level, ArchBoolDrag drag, ArchBoolPlacement placing )
{
var draft = placing.Cut;
var filed = host.Cuts;
var cut = new ArchCutPart
{
Id = plan.AllocateId(),
Name = placing.Op == ArchBoolOp.Damage ? $"Damage{filed.Count + 1}" : $"Cut{filed.Count + 1}",
Level = host.Storey( level ),
Profile = CutProfile.Poly,
Affects = Aimed( placing.Reach, draft.Affects ),
Ramp = draft.Ramp,
RampFall = draft.RampFall,
// The gesture's, not the seed's, exactly as an added ramp takes it - and left on the seed either way.
RampYaw = draft.Ramp ? drag.Climb : draft.RampYaw,
Enclosure = draft.Enclosure,
WallThickness = draft.WallThickness,
Edge = draft.Edge,
EdgeWidth = draft.EdgeWidth,
Head = draft.Head,
HeadHeight = draft.HeadHeight,
HeadRoof = draft.HeadRoof,
Damage = placing.Op == ArchBoolOp.Damage ? draft.Damage : ArchDamageKind.None,
DamageAmount = draft.DamageAmount,
DamageCellWidth = draft.DamageCellWidth,
DamageCellLength = draft.DamageCellLength,
DamageDepth = draft.DamageDepth,
MasonryPattern = draft.MasonryPattern,
MasonryCarves = draft.MasonryCarves,
DamageDrop = draft.DamageDrop,
DamageTilt = draft.DamageTilt
};
cut.Segments.Add( new ArchCutSegment
{
Start = drag.Min,
End = drag.Max,
BaseHeight = drag.Bottom,
TopHeight = drag.Top,
Loop = drag.Loop()
} );
filed.Add( cut );
return cut;
}
}