Editor-side subtool for architectural boolean modifiers. It handles user gestures to add, subtract, damage, place fixtures, or add wall details by sketching rectangular or circular drags, computes placement geometry (planes, lifts, ramps), previews ghosts, and commits generated parts (platforms, cuts, pillars, beams, wall mods). It also builds the sidebar UI for options and delegates fixture/detail behaviors to specialized subtools.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// One tool for everything that is a box or a circle standing in, cut out of, or laid on something else:
// the plinth that used to be Platform, the shaft that had no subtool at all, and the block on a wall face
// that used to be Wall Detail. The gesture is always the same drag; the operation says what it means.
//
// What it BECOMES is decided by the stack under it, in order. A run nested beneath a circular shape
// traces that circle, so a trim on a round bool is a round trim - the tool never has to know the
// difference, because the follower asks its parent for its shape rather than being told a rectangle.
[Title( "Boolean Modifiers" ), Icon( "join_inner" ), Group( "11" )]
public sealed class ArchBoolSubtool( ArchTool owner ) : ArchSubtool( owner )
{
public override ArchSurface[] Surfaces => new[]
{
ArchSurface.Deck, ArchSurface.Foundation, ArchSurface.WallCap, ArchSurface.Pillar, ArchSurface.WallBase, ArchSurface.Trim,
ArchSurface.Sign, ArchSurface.Railing, ArchSurface.Soffit
};
readonly ArchPlatformPart standing = new();
readonly ArchCutPart sinking = new()
{
Damage = ArchDamageKind.Masonry,
BreakEdges = true,
DamageAmount = 1f,
DamageCellWidth = 16f,
DamageCellLength = 8f,
DamageDepth = 6f,
MasonryPattern = ArchMasonryPattern.ExposedEdge,
MasonryCarves = false,
Affects = ArchDamage.Targets( ArchDamageKind.Masonry )
};
readonly ArchWallModPart detail = new() { Side = ArchWallSide.OnSurface };
readonly ArchTrimPart edging = new() { Profile = "gutter", Surface = ArchSurface.Trim };
readonly ArchBeamPart timber = new();
ArchPillarPart column = new();
ArchFixtureSubtool fixtures;
ArchFixtureSubtool Fixtures => fixtures ??= new ArchFixtureSubtool( Owner, Refresh );
ArchBoolOp op = ArchBoolOp.Add;
ArchBoolAdd kind = ArchBoolAdd.Slab;
ArchBoolReach aim = ArchBoolReach.Dragged;
ArchBoolForm form = ArchBoolForm.Box;
ArchCursorFace face = ArchCursorFace.Floor;
int segments = ArchFootprint.LeastSegments;
float rise = 24f;
float recess = 4f;
float opening = 96f;
bool wallFace;
bool edged;
protected override ArchKind? DraftKind => op switch
{
ArchBoolOp.Add => kind switch
{
ArchBoolAdd.Slab or ArchBoolAdd.Ramp => ArchKind.Platform,
ArchBoolAdd.Pillar => ArchKind.Pillar,
_ => ArchKind.Beam
},
ArchBoolOp.Subtract => ArchKind.Cut,
ArchBoolOp.Damage => ArchKind.Cut,
ArchBoolOp.Fixture => Fixtures.MergedDraftKind,
_ => ArchKind.WallMod
};
public override bool Adopts => true;
protected override void Adopt( ArchSelection picked )
{
if ( picked?.Item is ArchLadderPart or ArchBalconyPart or ArchExteriorStairPart )
{
op = ArchBoolOp.Fixture;
Fixtures.MergedAdopt( picked );
}
}
public override void OnEnabled()
{
base.OnEnabled();
Fixtures.MergedEnable();
}
// A pillar's dressing is its TYPE's, the same one the Pillars shelf adopts - so a column dragged here
// and a column placed there are the same part with the same authored footing, plinth and capital.
ArchPillarType Pillar => Owner.FindPillarType( column.Type ) ?? Owner.DefaultPillar();
bool Hangs => op == ArchBoolOp.Add && kind is ArchBoolAdd.Beam or ArchBoolAdd.Slats;
// A plinth is poured on the ground, so the generator draws it back DOWN by the lift its building object
// carries and it lands in plan space - the ghost adds none. Everything else filed under a building rides
// that lift, so the ghost has to, or the preview stands a plinth's height away from its own geometry.
bool Lifted => op != ArchBoolOp.Add || kind is not (ArchBoolAdd.Slab or ArchBoolAdd.Ramp);
// Placed and then pulled about without leaving the tool: the shape you just dragged is the one you are
// most likely to want a face of moved, and switching to Select to reach its height is a bad answer.
protected override bool Adjusts => true;
protected override string Title() => "Boolean Modifiers";
protected override string Advice() => op switch
{
ArchBoolOp.Add => kind switch
{
ArchBoolAdd.Slab => "Drag a box or a circle on open ground — it stands as a solid.",
ArchBoolAdd.Ramp => "Drag from the foot to the head — the deck climbs the way you dragged, from the ground up to the rise.",
ArchBoolAdd.Pillar => "Drag the column's footprint — it stands from this storey's floor, dressed by the pillar type.",
ArchBoolAdd.Beam => "Drag the member — it hangs under the ceiling of the room you drew it in.",
_ => "Drag the zone — it fills with members under the ceiling of the room you drew it in."
},
ArchBoolOp.Subtract when sinking.Ramp
=> "Drag from the foot to the head — the floor it leaves behind climbs the way you dragged, so what is left under it is a ramp.",
ArchBoolOp.Subtract => Reach() switch
{
ArchBoolReach.IntoWall => "Drag across a wall to open only that wall and its earlier fittings between the sill and head.",
ArchBoolReach.IntoCeiling => "Drag a box or a circle — it is taken up into the ceiling body only, so a shallow one leaves a coffer.",
ArchBoolReach.IntoFloor => "Drag a box or a circle — it is sunk into the floor slab only, so a shallow one leaves a tray.",
_ => "Drag a box or a circle — it opens this storey's floor and everything standing over it, out through the roof. Pull the top face down for a pit."
},
ArchBoolOp.Damage => $"Drag a damage zone, then choose which supported parts receive its {sinking.Damage.ToString().ToLowerInvariant()} response.",
ArchBoolOp.Fixture => Fixtures.MergedAdvice,
_ => detail.Kind.Plated()
? "Drag through a wall — every wall the box covers takes a plate at its head on the face you came from."
: "Drag through a wall — every wall the box covers takes a block on the face you came from."
};
// WHERE THE DRAG LANDED decides, unless the author has said otherwise. The face is REMEMBERED from the
// draw path and never asked for here: the cursor needs a viewport ray, and this is also read while the
// sidebar is being built, where there is no ray and asking for one throws.
ArchBoolReach Reach() => ArchBool.Aimed( aim, face, wallFace );
void Aiming()
{
if ( Owner.Cursor( out var cursor ) )
{
face = cursor.Face;
wallFace = cursor.SnappedTo is not null;
if ( Dragging && Owner.NearestWall( DragStart, out _, out _, out var distance ) is not null && distance < 0.5f )
{
wallFace = true;
}
}
}
// The drag, resolved once. Everything below reads this and nothing derives its own loop.
ArchBoolDrag Sketch( Vector2 from, Vector2 to ) => Sketch( from, to, Host( from, to ) );
protected override void DrawHover( Vector2 point )
{
if ( op == ArchBoolOp.Fixture )
{
Fixtures.MergedHover( point );
return;
}
base.DrawHover( point );
}
ArchBoolDrag Sketch( Vector2 from, Vector2 to, ArchBoolHost host )
{
var floor = Owner.LevelHeight;
var min = Min( from, to );
var max = Max( from, to );
var plane = Plane( host, min, max, floor );
return ArchBool.Sketch( Owner.Grid, Owner.Kit, op, from, to, plane, Climb( min, max, plane ),
form, segments, Reach(), Depth(), Surface( min, max, floor ) );
}
float Depth() => Reach() == ArchBoolReach.IntoWall ? opening : recess;
// What the drag stands on, resolved once a frame and handed to everything that needs it - the plane it is
// measured off, the lift it is drawn at, and the list the placement files it in.
ArchBoolHost Host( Vector2 from, Vector2 to )
{
return ArchBool.Host( Owner.Plan, Owner.ActiveBuilding(), (Min( from, to ) + Max( from, to )) * 0.5f );
}
// A plinth is poured on the ground it covers - the LOWEST point of it, or a downhill corner hangs. A
// column stands on the storey's own floor. A beam stands its DROP below the plane it hangs from, so its
// top lands exactly on the soffit rather than a drop past it.
float Plane( ArchBoolHost host, Vector2 min, Vector2 max, float floor )
{
if ( op != ArchBoolOp.Add )
{
// Out on the street there is no storey to measure off, and the road climbs with the ground under it:
// held to the level plane, a cut aimed at a road over a hill opened the air above it.
return host.Road is null ? floor : ArchPlatform.Grade( Owner.Scene, min, max, floor );
}
return kind switch
{
ArchBoolAdd.Slab or ArchBoolAdd.Ramp => ArchPlatform.Grade( Owner.Scene, min, max, floor ),
ArchBoolAdd.Pillar => floor,
_ => Soffit( min, max, floor ) - MathF.Max( 1f, timber.Drop )
};
}
// A cut is measured the other way: it runs to the head of the tallest thing it stands under - off the same
// plane it starts on, or a cut sunk into a hillside road reaches a storey's worth short of the sky.
float Climb( Vector2 min, Vector2 max, float plane )
{
if ( op == ArchBoolOp.Add )
{
return Hangs ? MathF.Max( 1f, timber.Drop ) : rise;
}
return ArchBool.Through( Owner.Plan, Owner.Kit, Owner.Level, min, max, plane );
}
// The face a recess is taken out of: the slab you are standing on, or the ceiling over the room.
float Surface( Vector2 min, Vector2 max, float floor )
{
return Reach() == ArchBoolReach.IntoCeiling ? Soffit( min, max, floor ) : floor;
}
float Soffit( Vector2 min, Vector2 max, float floor )
{
var centre = (min + max) * 0.5f;
if ( Owner.RoomOnLevel( centre ) is { } room && Owner.Plan.OwnerOf( room ) is { } building )
{
return ArchFloorGen.Overhead( Owner.Plan, building, room, Owner.Kit, centre );
}
return floor + Owner.Kit.WallHeight - ArchFloorGen.CeilingDepth( Owner.Kit );
}
protected override void DrawPreview()
{
if ( op == ArchBoolOp.Fixture )
{
Fixtures.MergedPreview( DragStart, DragCurrent );
return;
}
Aiming();
if ( op == ArchBoolOp.Detail )
{
DrawRectPreview();
DrawCovered( Covered( DragStart, DragCurrent ).ToList() );
return;
}
var host = Host( DragStart, DragCurrent );
var drag = Sketch( DragStart, DragCurrent, host );
var loop = drag.Loop();
var lift = host.Building is null || !Lifted ? 0f : ArchAsks.Lift( Owner.Plan, host.Building, Owner.Kit );
ArchGhost.Solid( loop, Floor( drag, lift ), Deck( drag, lift ) );
ArchGhost.Footprint( loop, Plate( drag ) + lift, ArchGhost.Nth( (int)op ) );
ArchGhost.Note( new Vector3( drag.Max.x, drag.Max.y, drag.Top + lift ),
$"{Named()} — {drag.Max.x - drag.Min.x:0} x {drag.Max.y - drag.Min.y:0}, {drag.Rise:0} {(op == ArchBoolOp.Add ? "high" : "deep")}{Sides()}{Members( drag )}{Gradient( drag )}" );
}
// An added ramp rakes the top it stands; a subtracted one rakes the floor it leaves. One question either way.
bool Ramps => op == ArchBoolOp.Add ? kind == ArchBoolAdd.Ramp : op == ArchBoolOp.Subtract && sinking.Ramp;
// The plate reads as the shape's plan, so it lies on the top of anything standing level and on the ground
// under anything sunk or raked - a plate at a ramp's head floats over the whole of its run.
float Plate( ArchBoolDrag drag ) => op == ArchBoolOp.Add && !Ramps ? drag.Top : drag.Bottom;
// Through the ramp's own resolve, so the wedge the preview draws is the wedge the generator carves.
ArchCarvePlane Deck( ArchBoolDrag drag, float lift )
{
return Ramps && op == ArchBoolOp.Add
? Raked( drag, drag.Top + lift )
: ArchCarvePlane.Level( drag.Top + lift );
}
ArchCarvePlane Floor( ArchBoolDrag drag, float lift )
{
return Ramps && op == ArchBoolOp.Subtract
? Raked( drag, drag.Top + lift )
: ArchCarvePlane.Level( drag.Bottom + lift );
}
ArchCarvePlane Raked( ArchBoolDrag drag, float head )
{
return ArchRamp.Deck( drag.Loop(), head, Fall( drag ), drag.Climb );
}
// The seed's authored fall for a cut, and the whole rise for an added ramp, whose sidebar leaves it alone
// until the shape is standing.
float Fall( ArchBoolDrag drag ) => op == ArchBoolOp.Subtract ? ArchRamp.Fall( sinking, drag.Rise ) : drag.Rise;
// A ramp is judged by its GRADIENT rather than its height - that is the number that says whether it can be
// walked up - and it comes off the same run the deck plane is spread over.
string Gradient( ArchBoolDrag drag )
{
if ( !Ramps )
{
return "";
}
var run = ArchRamp.Run( drag.Loop(), ArchRamp.Climb( drag.Climb ) );
var fall = Fall( drag );
return run < 1f || fall < 1f ? "" : $" · 1:{run / fall:0.#}";
}
string Named() => op switch
{
ArchBoolOp.Add => kind.ToString().ToLowerInvariant(),
ArchBoolOp.Subtract when sinking.Ramp => "ramp cut",
ArchBoolOp.Subtract => Reach() switch
{
ArchBoolReach.Through => "cut",
ArchBoolReach.IntoWall => "wall cut",
_ => "recess"
},
ArchBoolOp.Damage => "damage",
_ => "block"
};
// Counted by the generator's own resolve, so the number the preview promises is the number it lays.
string Members( ArchBoolDrag drag )
{
if ( kind != ArchBoolAdd.Slats || op != ArchBoolOp.Add )
{
return "";
}
return $" · {ArchBeamGen.Lanes( ArchBool.Hung( drag, Placement( null ) ) )} members";
}
string Sides() => form == ArchBoolForm.Circle ? $" · {segments} sides" : "";
protected override void OnDrag( Vector2 from, Vector2 to )
{
if ( op == ArchBoolOp.Fixture )
{
if ( Fixtures.MergedPlace( from, to ) is { } fixture )
{
var fixtureId = fixture switch
{
ArchLadderPart ladder => ladder.Id,
ArchBalconyPart balcony => balcony.Id,
ArchExteriorStairPart flight => flight.Id,
_ => 0
};
Drew( fixture );
FinishDraft( fixtureId );
}
return;
}
if ( op == ArchBoolOp.Detail )
{
PlaceDetail( from, to );
return;
}
var host = Host( from, to );
var drag = Sketch( from, to, host );
var room = Owner.RoomOnLevel( (drag.Min + drag.Max) * 0.5f );
if ( ArchBool.Place( Owner.Plan, host, Owner.Level, drag, Placement( room ) ) is not { } placed )
{
Log.Info( Refused( room ) );
return;
}
var id = placed switch
{
ArchPlatformPart platform => platform.Id,
ArchCutPart cut => cut.Id,
ArchPillarPart pillar => pillar.Id,
ArchBeamPart beam => beam.Id,
_ => 0
};
if ( edged )
{
Edge( room, id );
}
FinishDraft( id );
if ( host.Building is not null )
{
Owner.ActiveBuildingId = host.Building.Id;
}
// Picked straight away, so the sidebar is already on the shape the moment it exists - a shape you have
// to go and find before you can read its numbers is one you cannot see the numbers of. Its widgets wait
// for the next gesture though, or they cover the surface the next one gets drawn on.
Owner.Picked = new ArchSelection
{
Item = placed,
Room = room,
Building = placed is ArchPlatformPart placedPlatform ? Owner.Plan.OwnerOf( placedPlatform ) : host.Building
};
Drew( placed );
Owner.Commit( $"Boolean {op}" );
// The panel changes hands here: the advice becomes the sizing phase's and the picked part's own sheet
// opens under it, so the numbers you reach for next are already on screen.
Refresh();
}
ArchBoolPlacement Placement( ArchRoom room )
{
return new ArchBoolPlacement
{
Op = op,
Kind = kind,
Reach = Reach(),
Room = room,
Platform = standing,
Cut = sinking,
Pillar = column,
Beam = timber
};
}
string Refused( ArchRoom room )
{
if ( op == ArchBoolOp.Add && Host( DragStart, DragCurrent ).Building is null )
{
return $"Architecture: a {kind.ToString().ToLowerInvariant()} stands on a storey and a road has none — out there only Subtract applies.";
}
if ( room is null && op == ArchBoolOp.Add && kind is not (ArchBoolAdd.Slab or ArchBoolAdd.Ramp) )
{
return $"Architecture: a {kind.ToString().ToLowerInvariant()} is filed under a room, and there is none on this storey under that drag.";
}
return $"Architecture: that drag is smaller than {ArchBool.MinSize} inches on a side, so there is nothing in it.";
}
// The modifier the stack applies over the shape: a run filed under the bool that traces its outline,
// so a circular cut comes out with a circular trim and a box with a square one. Ordered, because it
// is authored after the shape it reads - it is the next operation, not a property of the first.
// The room the DRAG landed in, handed down rather than looked up again: asked for by the storey number it
// resolved a point at the world origin, so every edge run was filed on whatever room happened to be first.
void Edge( ArchRoom under, int hostId )
{
var room = under ?? Owner.ActiveRoom();
if ( room is null || hostId == 0 )
{
return;
}
var trim = new ArchTrimPart
{
Id = Owner.Plan.AllocateId(),
Name = "Edge Trim",
Profile = edging.Profile,
Surface = edging.Surface,
FollowsId = hostId,
Closed = true
};
if ( ArchTrimAffector.Refollow( Owner.Plan, trim ) )
{
room.Trims.Add( trim );
}
}
void PlaceDetail( Vector2 from, Vector2 to )
{
var placing = Covered( from, to ).ToList();
if ( placing.Count == 0 )
{
Log.Info( "Architecture: no wall under that box — drag one that covers a wall, or start it in the room and pull it through." );
return;
}
foreach ( var pending in placing )
{
pending.Part.Id = Owner.Plan.AllocateId();
pending.Part.Name = $"{pending.Part.Kind} {pending.Wall.Modifiers.Count + 1}";
pending.Wall.Modifiers.Add( pending.Part );
}
FinishDraft( placing[0].Part.Id );
Owner.Commit( $"Place {placing.Count} {detail.Kind}" );
}
void DrawCovered( IReadOnlyList<Pending> pending )
{
foreach ( var block in pending )
{
ArchWallModBlocks.Preview( block.Part, block.Wall, Owner.Kit, block.Height, block.Floor );
}
}
// Every wall the box covers, so one box over a corner piers both elevations. A box too small to be a
// drag is a click, and a click means the nearest wall at the set width.
IEnumerable<Pending> Covered( Vector2 from, Vector2 to )
{
var min = Min( from, to );
var max = Max( from, to );
if ( MathF.Max( max.x - min.x, max.y - min.y ) < Owner.Grid.SubgridSize() )
{
var nearest = Owner.NearestWall( from, out var room, out var along, out var distance );
if ( nearest is not null && room is not null && distance <= Reach( nearest ) )
{
yield return Placed( nearest, room, along, MathF.Max( ArchGridService.FinestSize, detail.Width ), from );
}
yield break;
}
foreach ( var room in Owner.Plan.AllRooms().Where( entry => entry.Floor == Owner.Level ) )
{
foreach ( var wall in room.Walls )
{
if ( Crosses( wall, min, max, out var along, out var width ) )
{
yield return Placed( wall, room, along, width, (min + max) * 0.5f );
}
}
}
}
// The box against one wall: how far along its run it reaches, and whether it comes near enough across
// to be standing on it at all.
bool Crosses( ArchWall wall, Vector2 min, Vector2 max, out float along, out float width )
{
along = 0f;
width = 0f;
if ( wall.Length < 1f )
{
return false;
}
var corners = new[] { min, new Vector2( max.x, min.y ), max, new Vector2( min.x, max.y ) };
var runs = corners.Select( corner => Vector2.Dot( corner - wall.Start, wall.Direction ) ).ToList();
var sides = corners.Select( corner => Vector2.Dot( corner - wall.Start, wall.Normal ) ).ToList();
if ( sides.Min() > Reach( wall ) || sides.Max() < -Reach( wall ) )
{
return false;
}
var start = MathF.Max( runs.Min(), 0f );
var finish = MathF.Min( runs.Max(), wall.Length );
if ( finish - start < ArchGridService.FinestSize )
{
return false;
}
along = (start + finish) * 0.5f;
width = finish - start;
return true;
}
// A wall counts as covered while the box is within its thickness plus a grid cell, so a box dragged
// out of the room does not have to land exactly on the skin.
float Reach( ArchWall wall ) => ArchWallSection.Thickness( wall, Owner.Kit ) * 0.5f + Owner.Kit.GridSize;
Pending Placed( ArchWall wall, ArchRoom room, float along, float width, Vector2 at )
{
var building = Owner.Plan.OwnerOf( room );
var lift = building is null ? 0f : ArchAsks.Lift( Owner.Plan, building, Owner.Kit );
var standing = ArchWallSection.Height( wall, room, Owner.Kit );
return new Pending
{
Wall = wall,
Room = room,
Height = standing,
Floor = room.BaseHeight + lift,
Part = new ArchWallModPart
{
Kind = detail.Kind,
Side = Facing( wall, at ),
Along = ArchGridService.Fine( along ),
Width = ArchGridService.Fine( width ),
Base = detail.Kind.Seat( detail.Base, standing ),
// Zero climbs to the wall's own head. It is dragged up from there on the part's own head
// widget - a block that asked how many storeys it crossed made the author do the sum. A
// plate is the exception: it takes a band of its own, seated where a fascia board goes.
Height = detail.Kind.Rise( 0f ),
Depth = detail.Depth,
Field = detail.Field
}
};
}
// On surface is answered here and nowhere else: the side of the centreline the box sits on is the face
// the author pointed at, and what gets authored is a plain Outside or Inside.
ArchWallSide Facing( ArchWall wall, Vector2 at )
{
if ( detail.Side != ArchWallSide.OnSurface )
{
return detail.Side;
}
return Vector2.Dot( at - wall.Start, wall.Normal ) < 0f ? ArchWallSide.Outside : ArchWallSide.Inside;
}
static List<Vector2> Loop( ArchWall wall, float from, float to, float near, float far )
{
return new List<Vector2>
{
Point( wall, from, near ),
Point( wall, to, near ),
Point( wall, to, far ),
Point( wall, from, far )
};
}
static Vector2 Point( ArchWall wall, float along, float across ) => wall.PointAt( along ) + wall.Normal * across;
// The GESTURE stays whatever is picked - what the next drag makes is this tool's own question, and a
// panel that stood the kind picker down as soon as something was placed left no route back to a pillar.
// The DRESSING stands down, because the picked part's own sheet below is the same form off the same
// records: shown together they are one panel asking for the same numbers twice, and two icon grids of
// the same choice light independently.
protected override void BuildOptions( ToolSidebarWidget panel )
{
var editing = Editing();
var seeding = editing is null;
BuildOperation( panel );
if ( op == ArchBoolOp.Fixture )
{
Fixtures.MergedOptions( panel );
}
else if ( op == ArchBoolOp.Detail )
{
// The detail's kind and face are in its own sheet too, so unlike Shape and Reach they cannot stay:
// two identical grids, one writing the seed and one the selection, is the panel telling you the
// picked block changed when it did not.
if ( seeding )
{
BuildDetailOptions( panel );
}
}
else
{
ArchBoolUi.Shape( panel, form, segments,
chosen => { form = chosen; Refresh(); },
sides => { segments = sides; Refresh(); } );
if ( op != ArchBoolOp.Add )
{
BuildReachOptions( panel );
}
if ( seeding )
{
if ( op == ArchBoolOp.Add )
{
BuildAddOptions( panel );
}
else if ( op == ArchBoolOp.Damage )
{
ArchBoolUi.Damage( panel, sinking, Refresh, null );
}
else
{
// It reaches UP from the storey you drew it on, because a cut opens its own level and every one
// above it - calling that a depth had the author expecting it to go the other way.
ArchBoolUi.Cut( panel, sinking, Refresh, null );
}
BuildEdgeOptions( panel );
}
}
BuildPicked( panel, editing );
}
// The operation and what it stands are one question - what is this drag going to be - so they share a
// section and read as one row of choices rather than two boxes stacked.
void BuildOperation( ToolSidebarWidget panel )
{
ArchSidebarSection.Show( panel, Scope( "operation" ), "Operation", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
grid.Pick( "Add — the shape stands as a solid", "bool_add", "add_box", op == ArchBoolOp.Add,
() => { op = ArchBoolOp.Add; Refresh(); } );
grid.Pick( "Subtract — the shape is taken out of everything it passes through", "bool_subtract", "disabled_by_default",
op == ArchBoolOp.Subtract, () => { op = ArchBoolOp.Subtract; Refresh(); } );
grid.Pick( "Damage — an influence zone where supported generators add their damage response", "bool_damage", "broken_image",
op == ArchBoolOp.Damage, () => { op = ArchBoolOp.Damage; Refresh(); } );
grid.Pick( "Fixtures — ladders, balconies and exterior stairs hung from a wall", "bool_fixtures", "table_rows",
op == ArchBoolOp.Fixture, () => { op = ArchBoolOp.Fixture; Refresh(); } );
grid.Pick( "Detail — the shape lands on the wall faces the box covers", "bool_detail", "view_column",
op == ArchBoolOp.Detail, () => { op = ArchBoolOp.Detail; Refresh(); } );
}
if ( op != ArchBoolOp.Add )
{
return;
}
using var kinds = ArchIconGrid.In( group );
kinds.Pick( "Slab — a plinth poured on the ground it covers", "bool_slab", "crop_landscape",
kind == ArchBoolAdd.Slab, () => Adopt( ArchBoolAdd.Slab ) );
kinds.Pick( "Ramp — the same slab with a raked deck, climbing the way you drag", "bool_ramp", "trending_up",
kind == ArchBoolAdd.Ramp, () => Adopt( ArchBoolAdd.Ramp ) );
kinds.Pick( "Pillar — a column on the dragged footprint, dressed by a pillar type", "bool_pillar", "view_column",
kind == ArchBoolAdd.Pillar, () => Adopt( ArchBoolAdd.Pillar ) );
kinds.Pick( "Beam — one member hung under the ceiling", "bool_beam", "horizontal_rule",
kind == ArchBoolAdd.Beam, () => Adopt( ArchBoolAdd.Beam ) );
kinds.Pick( "Slats — the shape filled with members", "bool_slats", "view_stream",
kind == ArchBoolAdd.Slats, () => Adopt( ArchBoolAdd.Slats ) );
} );
}
// The picked part's numbers are the same sheet Select shows, so those two cannot drift. Nothing in this
// tool deselects, so Deselect is offered here rather than being a thing you must leave the tool to do -
// and it is what puts the seed form back.
void BuildPicked( ToolSidebarWidget panel, ArchSelection editing )
{
if ( editing is null )
{
return;
}
ArchSidebarSection.Show( panel, Scope( "selected" ), $"Selected — {editing.Describe()}", group =>
group.Add( new Button( "Deselect", "close" ) { Clicked = () => { Owner.Select( null ); Refresh(); } } ) );
ArchPartUi.Sheet( panel, Owner, editing, Refresh );
}
ArchSelection Editing()
{
return Owner.Picked?.Item is ArchPlatformPart or ArchCutPart or ArchBeamPart or ArchPillarPart or ArchWallModPart
? Owner.Picked
: null;
}
// A column and a ramp both climb a storey, where a plinth is a step - switching kind without carrying the
// height over is what left a 24-inch stump where a pillar was asked for.
void Adopt( ArchBoolAdd chosen )
{
if ( chosen != kind )
{
rise = Rising( chosen );
}
kind = chosen;
Refresh();
}
float Rising( ArchBoolAdd chosen ) => chosen switch
{
ArchBoolAdd.Pillar or ArchBoolAdd.Ramp => Owner.Kit.WallHeight,
ArchBoolAdd.Slab => 24f,
_ => rise
};
void BuildAddOptions( ToolSidebarWidget panel )
{
if ( kind == ArchBoolAdd.Pillar )
{
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
group.Add( ArchPartUi.Number( "Height", rise, 128f, value => rise = MathF.Max( 4f, value ) ) ) );
// A column dragged here is the same part the Pillars shelf places, so its dressing is its type's and
// is folded away behind the same disclosure that tool uses.
ArchSidebarSection.Disclosure( panel, Scope( "column" ), "Pillar type", true, group =>
ArchSidebarLayout.Form( group, form =>
{
ArchAsks.PillarKinds( form, Owner.OfferedPillars(), column.Type, Dress );
ArchAsks.BuildPillar( form, column, Pillar, Refresh, null );
} ) );
return;
}
if ( Hangs )
{
// A single member is the shape you dragged and nothing else, so it has no section to choose. A
// field repeats INSIDE that same shape, and what it repeats is a pillar type lying down.
if ( kind == ArchBoolAdd.Slats )
{
ArchSidebarLayout.Form( panel.Layout, form =>
ArchAsks.PillarKinds( form, Owner.OfferedPillars(), timber.Type,
type => { ArchBeamGen.Dressed( timber, type ); Refresh(); } ) );
}
ArchBoolUi.Section( panel, timber, kind == ArchBoolAdd.Slats, null );
return;
}
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
group.Add( ArchPartUi.Number( Ramps ? "Head" : "Rise", rise, 24f, value => rise = MathF.Max( 4f, value ) ) ) );
// A raked deck wears no coping, and its fall is left at the whole rise until the shape is standing -
// a shelf under a ramp is a number worth typing against geometry rather than against a preview.
if ( Ramps )
{
return;
}
ArchSidebarSection.Disclosure( panel, Scope( "guardrail" ), "Guardrail", true, guard =>
{
guard.Add( ArchPartUi.Check( "Guard rail round the edge", standing.Guardrail, value => { standing.Guardrail = value; Refresh(); } ) );
if ( standing.Guardrail )
{
guard.Add( ArchPartUi.Number( "Height", standing.GuardHeight, 42f, value => standing.GuardHeight = MathF.Max( 12f, value ) ) );
}
} );
ArchSidebarSection.Disclosure( panel, Scope( "coping" ), "Coping", true, coping =>
{
coping.Add( ArchPartUi.Check( "Coping band", standing.Coping, value => { standing.Coping = value; Refresh(); } ) );
if ( !standing.Coping )
{
return;
}
coping.Add( ArchPartUi.Number( "Height", standing.CopingHeight, 4f, value => standing.CopingHeight = value ) );
coping.Add( ArchPartUi.Number( "Width", standing.CopingWidth, 8f, value => standing.CopingWidth = value ) );
coping.Add( ArchPartUi.Number( "Oversail", standing.CopingOversail, 1.5f, value => standing.CopingOversail = value ) );
coping.Add( ArchPartUi.Check( "Round the inside edges too", standing.CopingInside, value => standing.CopingInside = value ) );
} );
}
// The whole section comes off the type, exactly as the Pillars shelf adopts one, so nothing about the
// column is authored twice.
void Dress( ArchPillarType type )
{
column = type.Standing( default, 0f, 0f );
Refresh();
}
// Where the cut's band comes from. A recess is the reason the ceiling has a body at all: aimed into it,
// the drag stops inside and leaves the plaster over it standing.
void BuildReachOptions( ToolSidebarWidget panel )
{
ArchSidebarSection.Show( panel, Scope( "reach" ), "Reach", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
grid.Pick( "Where you drag — the floor looking down, the ceiling looking up at it", "bool_dragged", "ads_click",
aim == ArchBoolReach.Dragged, () => { aim = ArchBoolReach.Dragged; Refresh(); } );
grid.Pick( "Through — out past everything standing over it", "bool_through", "vertical_align_top",
aim == ArchBoolReach.Through, () => { aim = ArchBoolReach.Through; Refresh(); } );
grid.Pick( "Into the ceiling — a coffer, taken up into the ceiling body only", "bool_coffer", "border_top",
aim == ArchBoolReach.IntoCeiling, () => { aim = ArchBoolReach.IntoCeiling; Refresh(); } );
grid.Pick( "Into the floor — a tray, sunk into the slab only", "bool_tray", "border_bottom",
aim == ArchBoolReach.IntoFloor, () => { aim = ArchBoolReach.IntoFloor; Refresh(); } );
grid.Pick( "Into the wall — an opening between its sill and head", "bool_wall", "door_open",
aim == ArchBoolReach.IntoWall, () => { aim = ArchBoolReach.IntoWall; Refresh(); } );
}
if ( aim == ArchBoolReach.Through )
{
return;
}
if ( aim == ArchBoolReach.IntoWall )
{
group.Add( ArchPartUi.Number( "Opening height", opening, 96f, value => opening = MathF.Max( 4f, value ) ) );
return;
}
group.Add( ArchPartUi.Number( "Recess depth", recess, 4f, value => recess = MathF.Max( 1f, value ) ) );
} );
}
// The next operation in the stack, offered at placement so the shape and the run it wears land as one
// edit. It is still an ordinary layer afterwards - disable it, re-order it, or add another.
void BuildEdgeOptions( ToolSidebarWidget panel )
{
ArchSidebarSection.Disclosure( panel, Scope( "edge" ), "Edge modifier", true, edge =>
{
edge.Add( ArchPartUi.Check( "Trim the edge", edged, value => { edged = value; Refresh(); } ) );
if ( !edged )
{
return;
}
edge.Add( Wrapped( "A run filed under the shape, tracing its outline — round on a circle, square on a box." ) );
ArchSidebarLayout.Form( edge, form => ArchPartUi.Trim( form, edging, Owner.Kit, null ) );
} );
}
void BuildDetailOptions( ToolSidebarWidget panel )
{
ArchWallForms.ModKinds( panel, detail.Kind, kind =>
{
detail.Kind = kind;
detail.Field = kind.Field();
Refresh();
} );
ArchWallForms.ModSides( panel, detail.Side, true, side => { detail.Side = side; Refresh(); } );
ArchWallForms.ModSizes( panel, detail, null, false );
ArchWallForms.ModFill( panel, detail, null );
panel.Layout.Add( ArchSidebarLayout.Advice( detail.Kind.Plated()
? "Seated at its wall's head like a fascia board — drag the plate's foot and head widgets to move it down the elevation."
: "Placed at its wall's own head — drag the block's head widget to run it past the floor line." ) );
}
sealed class Pending
{
public ArchWall Wall;
public ArchRoom Room;
public float Height;
public float Floor;
public ArchWallModPart Part;
}
}