Editor/Handles/ArchHandles.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchHandles {
public static bool Draw( ArchTool tool, ArchSelection picked ) {
ArchShapeHandles.BeginFrame();
if ( !Gizmo.IsLeftMouseDown ) {
heldPivots.Clear();
}
if ( picked?.Item is null ) {
return false;
}
if ( tool.LayerTree.IsLocked( picked.Item ) ) {
return false;
}
var node = tool.LayerTree.Find( picked.Item );
if ( node?.Ref is { } layer && tool.LayerHidden( layer.ItemId ) ) {
return false;
}
var kind = node?.Kind ?? ArchKinds.Load().Claiming( picked.Item )?.Kind ?? default;
if ( ArchHandlers.Load().For( kind ) is { } handler ) {
return handler.Draw( tool, picked );
}
return picked.Item switch {
ArchSiteAssembly group => Assembly( tool, group ),
ArchBuilding building => Blueprint( tool, building ),
ArchRoofPart roof => Roof( tool, roof, picked.Building ),
ArchPlatformPart platform => Platform( tool, platform ),
ArchBeamPart beam => Beam( tool, beam ),
ArchCutPart cut => Cut( tool, cut ),
ArchRoom room => Room( tool, room ),
ArchWall wall => Wall( tool, wall, picked.Room ),
ArchOpening opening => Opening( tool, opening, picked.Room ),
ArchWallModPart modifier => Modifier( tool, modifier, picked.Room ),
ArchTrimPart trim => Trim( tool, trim ),
ArchDownpipePart pipe => Downpipe( tool, pipe ),
ArchLadderPart ladder => Ladder( tool, ladder ),
ArchBalconyPart balcony => Balcony( tool, balcony ),
ArchApproachPart approach when approach.IsSpline => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Approach, approach.Id ), approach.Nodes ),
ArchApproachPart approach => Approach( tool, approach ),
ArchFencePart fence => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Fence, fence.Id ), fence.Nodes ),
ArchRoadPart road => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Road, road.Id ), road.Nodes ),
_ => false
};
}
public static bool Bounds( ArchBuilding building, out Vector2 min, out Vector2 max ) {
var low = new Vector2( float.MaxValue, float.MaxValue );
var high = new Vector2( float.MinValue, float.MinValue );
var found = false;
foreach ( var room in building.Rooms ) {
var loop = ArchFloorGen.Footprint( room );
if ( loop.Count < 3 ) {
continue;
}
ArchFootprint.Bounds( loop, out var roomMin, out var roomMax );
low = Vector2.Min( low, roomMin );
high = Vector2.Max( high, roomMax );
found = true;
}
min = low;
max = high;
return found && high.x - low.x > 1f && high.y - low.y > 1f;
}
// Pivot is held for the whole gesture so an L's shifting bounds centre doesn't walk the building sideways.
static readonly Dictionary<int, Vector2> heldPivots = new();
public static bool Pivot( ArchBuilding building, out Vector2 about ) {
about = Vector2.Zero;
if ( Gizmo.IsLeftMouseDown && building is not null && heldPivots.TryGetValue( building.Id, out var held ) ) {
about = held;
return true;
}
if ( building is null || !Bounds( building, out var min, out var max ) ) {
return false;
}
about = (min + max) * 0.5f;
if ( Gizmo.IsLeftMouseDown ) {
heldPivots[building.Id] = about;
}
return true;
}
// Outline handles only on free decks — room-clipped decks are moved via the shell.
static bool Roof( ArchTool tool, ArchRoofPart roof, ArchBuilding building ) {
var lift = building is null ? 0f : ArchAsks.Lift( tool.Plan, building, tool.Kit );
var height = roof.BaseHeight + lift;
var outline = roof.Outline();
var gesture = ArchGesture.On( ArchKind.Roof, roof.Id );
var free = FreeDeck( roof, building );
var changed = free && ArchShapeHandles.Corners( tool, gesture, outline, height );
changed |= free && ArchShapeHandles.Edges( tool, gesture, outline, height );
if ( changed ) {
var from = roof.Outline();
ArchFootprint.Bounds( from, out var fromMin, out var fromMax );
ArchFootprint.Bounds( outline, out var toMin, out var toMax );
var map = ArchRemap.Between( fromMin, fromMax, toMin, toMax );
roof.Reshape( outline );
foreach ( var light in roof.Lights ) {
var lightMin = light.Min;
var lightMax = light.Max;
map.Box( ref lightMin, ref lightMax );
light.Min = lightMin;
light.Max = lightMax;
}
}
if ( ArchShapeHandles.Height( tool, gesture.At( "eave" ), Middle( outline ), roof.BaseHeight, out var eave, Gizmo.Colors.Roll ) ) {
roof.BaseHeight = eave;
changed = true;
}
changed |= Rake( tool, roof, gesture, lift );
return changed;
}
static bool Rake( ArchTool tool, ArchRoofPart roof, ArchGesture gesture, float lift ) {
foreach ( var grip in ArchRoofRake.Grips( roof ) ) {
if ( !ArchShapeHandles.Lift( tool, gesture.At( "rake", grip.Index ), grip.At, grip.Height + lift, out var raked, Gizmo.Colors.Pitch ) ) {
continue;
}
ArchRoofRake.Apply( roof, grip.At, grip.Outward, raked - lift );
return true;
}
return false;
}
static bool FreeDeck( ArchRoofPart roof, ArchBuilding building ) {
if ( building is null ) {
return true;
}
ArchFootprint.Bounds( roof.Outline(), out var min, out var max );
foreach ( var room in building.Rooms.Where( room => room.Floor == roof.Level ) ) {
var loop = ArchFloorGen.Footprint( room );
if ( loop.Count < 3 ) {
continue;
}
ArchFootprint.Bounds( loop, out var low, out var high );
if ( low.x <= max.x && high.x >= min.x && low.y <= max.y && high.y >= min.y ) {
return false;
}
}
return true;
}
static bool Placed( ArchTool tool, ArchGesture gesture, Vector2 min, Vector2 max, float height, List<Vector2> outline, out Vector2 shifted, out float lifted, out float turned, float step = 0f ) {
var centre = (min + max) * 0.5f;
var at = new Vector3( centre.x, centre.y, height );
shifted = Vector2.Zero;
lifted = 0f;
turned = 0f;
if ( ArchShapeHandles.Position( tool, gesture.At( "move" ), at, out var moved ) ) {
shifted = new Vector2( moved.x - centre.x, moved.y - centre.y );
lifted = moved.z - height;
return true;
}
if ( ArchShapeHandles.Turned( gesture.At( "turn" ), at, out var spun, step ) ) {
turned = spun;
var swung = ArchFootprint.Turned( outline, centre, spun );
outline.Clear();
outline.AddRange( swung );
return true;
}
return false;
}
static bool Platform( ArchTool tool, ArchPlatformPart platform ) {
var outline = platform.Outline();
var gesture = ArchGesture.On( ArchKind.Platform, platform.Id );
if ( Placed( tool, gesture, platform.Min, platform.Max, platform.TopHeight, outline, out var shifted, out var lifted, out var swung ) ) {
var centre = (platform.Min + platform.Max) * 0.5f;
platform.Reshape( outline.Select( point => point + shifted ).ToList() );
platform.GradeHeight += lifted;
platform.TopHeight += lifted;
if ( platform.Ramp ) {
platform.RampYaw = ArchShapeHandles.Facing( platform.RampYaw + swung );
}
foreach ( var core in platform.Stairs.Select( stair => stair.Core ).Where( core => core is not null ) ) {
core.Origin = ArchFootprint.Turned( new[] { core.Origin }, centre, swung )[0] + shifted;
core.Yaw += swung;
}
return true;
}
// Regular shapes get box handles; irregular ones keep per-corner widgets.
var boxed = ArchOutlineForm.Of( outline ) == ArchBoolForm.Circle || ArchFootprint.IsRectilinear( outline );
if ( ArchShapeHandles.Volume( tool, gesture.At( "volume" ), platform.Min, platform.Max, platform.GradeHeight, platform.TopHeight, out var pulled ) ) {
return Pull( platform, outline, pulled );
}
if ( platform.Ramp
&& ArchShapeHandles.Height( tool, gesture.At( "foot" ), Toe( platform, outline ), ArchRamp.Foot( platform ), out var toe, Gizmo.Colors.Pitch ) ) {
platform.RampFall = Math.Clamp( platform.TopHeight - toe, ArchGridService.FinestSize, platform.Rise );
return true;
}
if ( boxed ) {
return false;
}
var changed = ArchShapeHandles.Corners( tool, gesture, outline, platform.TopHeight );
changed |= ArchShapeHandles.Edges( tool, gesture, outline, platform.TopHeight );
if ( changed ) {
var from = platform.Outline();
ArchFootprint.Bounds( from, out var fromMin, out var fromMax );
ArchFootprint.Bounds( outline, out var toMin, out var toMax );
var map = ArchRemap.Between( fromMin, fromMax, toMin, toMax );
platform.Reshape( outline );
foreach ( var stair in platform.Stairs ) {
ArchCarry.Stair( stair, map );
}
}
var middle = Middle( outline );
if ( ArchShapeHandles.Height( tool, gesture.At( "top" ), middle, platform.TopHeight, out var top, Gizmo.Colors.Roll ) ) {
platform.TopHeight = MathF.Max( platform.GradeHeight + 1f, top );
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "grade" ), middle, platform.GradeHeight, out var grade, Gizmo.Colors.Pitch ) ) {
platform.GradeHeight = MathF.Min( platform.TopHeight - 1f, grade );
changed = true;
}
return changed;
}
static Vector2 Toe( ArchPlatformPart platform, IReadOnlyList<Vector2> outline ) {
var climb = ArchRamp.Climb( platform.RampYaw );
return Middle( outline ) - climb * (ArchRamp.Run( outline, climb ) * 0.5f);
}
static bool Beam( ArchTool tool, ArchBeamPart beam ) {
var outline = beam.Outline();
var gesture = ArchGesture.On( ArchKind.Beam, beam.Id );
if ( Placed( tool, gesture, beam.Min, beam.Max, beam.TopHeight, outline, out var shifted, out var lifted, out var turned ) ) {
beam.Reshape( outline.Select( point => point + shifted ).ToList() );
beam.TopHeight += lifted;
beam.Yaw = ArchShapeHandles.Facing( beam.Yaw + turned );
return true;
}
if ( !ArchShapeHandles.Volume( tool, gesture.At( "volume" ), beam.Min, beam.Max, beam.Soffit, beam.TopHeight, out var pulled ) ) {
return false;
}
var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );
ArchRemap.Between( beam.Min, beam.Max, toMin, toMax ).Loop( outline );
beam.Reshape( outline );
beam.TopHeight = pulled.Maxs.z;
beam.Drop = MathF.Max( 1f, pulled.Maxs.z - pulled.Mins.z );
return true;
}
// Remaps the loop into new bounds rather than rebuilding, preserving circles and segment counts.
static bool Pull( ArchPlatformPart platform, List<Vector2> outline, BBox pulled ) {
var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );
var map = ArchRemap.Between( platform.Min, platform.Max, toMin, toMax );
map.Loop( outline );
platform.Reshape( outline );
platform.GradeHeight = pulled.Mins.z;
platform.TopHeight = MathF.Max( pulled.Mins.z + 1f, pulled.Maxs.z );
foreach ( var stair in platform.Stairs ) {
ArchCarry.Stair( stair, map );
}
return true;
}
static bool Cut( ArchTool tool, ArchCutPart cut ) {
var building = tool.Plan.OwnerOf( cut );
var lift = building is null ? 0f : ArchAsks.Lift( tool.Plan, building, tool.Kit );
var gesture = ArchGesture.On( ArchKind.Cut, cut.Id );
if ( cut.Segments.Count == 1 && cut.Segments[0] is { HasLoop: true } only ) {
var band = ArchCut.WorldBand( only, lift );
ArchFootprint.Bounds( only.Loop, out var low, out var high );
var step = cut.SnapAngle ? ArchGridService.AngleStep : 0f;
if ( Placed( tool, gesture, low, high, band.y, only.Loop.ToList(), out var shifted, out var lifted, out var swung, step ) ) {
ArchCut.Shift( only, shifted );
ArchCut.Turn( only, (low + high) * 0.5f, swung, cut.SnapAngle );
only.BaseHeight += lifted;
only.TopHeight += lifted;
return true;
}
return Volume( tool, gesture, only, lift );
}
var changed = false;
for ( var index = 0; index < cut.Segments.Count; index++ ) {
var segment = cut.Segments[index];
var leg = gesture.At( "leg", index );
var band = ArchCut.WorldBand( segment, lift );
if ( index == 0
&& ArchShapeHandles.Landed( tool, leg.At( "start" ), new Vector3( segment.Start.x, segment.Start.y, band.y ), out var start ) ) {
changed |= ArchCut.Reshape( segment, start, segment.End, cut.SnapAngle );
}
if ( ArchShapeHandles.Landed( tool, leg.At( "end" ), new Vector3( segment.End.x, segment.End.y, band.y ), out var end ) ) {
changed |= ArchCut.Reshape( segment, segment.Start, end, cut.SnapAngle );
}
var middle = segment.HasLoop ? Middle( segment.Loop ) : (segment.Start + segment.End) * 0.5f;
if ( segment.HasLoop ) {
changed |= ArchShapeHandles.Corners( tool, leg, segment.Loop, band.y );
} else {
var across = segment.Axes.Across;
var edge = middle + across * (MathF.Max( 1f, segment.Width ) * 0.5f);
if ( ArchShapeHandles.Width( tool, leg.At( "width" ), new Vector3( edge.x, edge.y, band.y ), middle, across, 12f, out var widened, Gizmo.Colors.Green ) ) {
segment.Width = widened;
changed = true;
}
}
if ( ArchShapeHandles.Height( tool, leg.At( "head" ), middle, band.y, out var head, Gizmo.Colors.Roll ) ) {
segment.TopHeight = MathF.Max( segment.BaseHeight + 1f, head - lift );
changed = true;
}
if ( ArchShapeHandles.Height( tool, leg.At( "foot" ), middle, band.x, out var foot, Gizmo.Colors.Pitch ) ) {
segment.BaseHeight = MathF.Min( segment.TopHeight - 1f, foot - lift );
changed = true;
}
}
if ( changed ) {
ArchCut.Relink( cut );
}
return changed;
}
static bool Volume( ArchTool tool, ArchGesture gesture, ArchCutSegment leg, float lift ) {
ArchFootprint.Bounds( leg.Loop, out var min, out var max );
var band = ArchCut.WorldBand( leg, lift );
if ( !ArchShapeHandles.Volume( tool, gesture, min, max, band.x, band.y, out var pulled ) ) {
return false;
}
var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );
var yaw = leg.Yaw;
ArchRemap.Between( min, max, toMin, toMax ).Loop( leg.Loop );
// Refit at existing yaw — deriving yaw from the new bounds would swing a turned cut.
ArchCut.Fit( leg, yaw );
leg.BaseHeight = pulled.Mins.z - lift;
leg.TopHeight = MathF.Max( leg.BaseHeight + 1f, pulled.Maxs.z - lift );
return true;
}
static bool Blueprint( ArchTool tool, ArchBuilding building ) {
if ( !Bounds( building, out var min, out var max ) ) {
return false;
}
var gesture = ArchGesture.On( ArchKind.Building, building.Id );
var height = tool.LevelHeight;
if ( Shell( building ) is { Count: >= 3 } shell ) {
if ( ArchShapeHandles.Corners( tool, gesture, shell, height, Carried( building ) )
| ArchShapeHandles.Edges( tool, gesture, shell, height, CarriedAlong( building ) ) ) {
return true;
}
} else if ( Boxed( tool, gesture, building, min, max, height ) ) {
return true;
}
var centre = (min + max) * 0.5f;
// Pivot must be captured before the ring reports movement.
var pivoted = Pivot( building, out var about );
if ( ArchShapeHandles.Turned( gesture.At( "turn" ), new Vector3( centre.x, centre.y, height ), out var swung, ArchGridService.AngleStep ) ) {
return pivoted && ArchCarry.Turn( building, about, swung, tool.Plan );
}
if ( !ArchShapeHandles.Move( tool, gesture.At( "body" ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
return false;
}
ArchCarry.Shift( building, moved - centre, tool.Plan );
return true;
}
// Bounds rescale: the new box is measured against the opposite corner, not re-derived bounds.
static bool Boxed( ArchTool tool, ArchGesture gesture, ArchBuilding building, Vector2 min, Vector2 max, float height ) {
var boxed = ArchFootprint.Rect( min, max );
var corners = ArchShapeHandles.Corners( tool, gesture, boxed, height );
var edges = ArchShapeHandles.Edges( tool, gesture, boxed, height );
if ( !corners && !edges ) {
return false;
}
var toMin = min;
var toMax = max;
if ( corners ) {
var rect = ArchFootprint.Rect( min, max );
var index = Enumerable.Range( 0, rect.Count ).First( at => (boxed[at] - rect[at]).Length > 0.001f );
var opposite = rect[(index + 2) % rect.Count];
toMin = Vector2.Min( boxed[index], opposite );
toMax = Vector2.Max( boxed[index], opposite );
} else {
ArchFootprint.Bounds( boxed, out toMin, out toMax );
}
if ( toMax.x - toMin.x < 1f || toMax.y - toMin.y < 1f ) {
return true;
}
ArchCarry.Unit( building, ArchRemap.Between( min, max, toMin, toMax ), tool.Plan );
return true;
}
// Shell is held for the whole gesture — re-tracing the union mid-drag would rebind corner indices.
static readonly Dictionary<int, List<Vector2>> heldShells = new();
public static List<Vector2> Shell( ArchBuilding building ) {
// Only held during scale gestures — move/turn need a live re-trace.
var binds = ArchShapeHandles.Mode == ArchHandleMode.Scale;
var holding = binds && Gizmo.IsLeftMouseDown;
if ( !holding ) {
heldShells.Clear();
} else if ( heldShells.TryGetValue( building.Id, out var held ) ) {
return held;
}
var outers = ArchFootprint.Outer( ArchFootprint.Union( building.Rooms
.Select( ArchFloorGen.Footprint )
.Where( loop => loop.Count >= 3 )
.ToList() ) ).ToList();
var shell = outers.Count == 1 ? outers[0] : null;
if ( holding && shell is not null ) {
heldShells[building.Id] = shell;
}
return shell;
}
static Func<Vector2, Vector2, bool> Carried( ArchBuilding building ) {
return ( from, to ) => ArchCarry.Corner( building, from, to );
}
static Func<Vector2, Vector2, Vector2, bool> CarriedAlong( ArchBuilding building ) {
return ( from, to, reach ) => ArchCarry.Along( building, from, to, reach );
}
static bool Assembly( ArchTool tool, ArchSiteAssembly group ) {
var members = Members( tool.Plan, group ).ToList();
if ( members.Count == 0 || !Bounds( tool.Plan, members, out var min, out var max ) ) {
return false;
}
var centre = (min + max) * 0.5f;
if ( !ArchShapeHandles.Move( tool, ArchGesture.On( ArchKind.Assembly, group.Id ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
return false;
}
var map = ArchRemap.Translation( moved - centre );
foreach ( var member in members ) {
switch ( member ) {
case ArchBuilding building:
ArchCarry.Unit( building, map, tool.Plan );
break;
case ArchRoom room:
ArchCarry.Room( room, map );
break;
case ArchRoofPart roof:
ArchCarry.Roof( roof, map );
break;
case ArchRoadPart road:
map.Nodes( road.Nodes );
break;
}
}
return true;
}
static IEnumerable<object> Members( ArchPlan plan, ArchSiteAssembly group ) {
var owned = plan.Buildings.Where( building => group.Children.Contains( building.Id ) ).ToList();
foreach ( var building in owned ) {
yield return building;
}
foreach ( var room in plan.AllRooms().Where( room => group.Children.Contains( room.Id ) ) ) {
if ( !owned.Contains( plan.OwnerOf( room ) ) ) {
yield return room;
}
}
foreach ( var roof in plan.Buildings.SelectMany( building => building.Roofs ).Where( roof => group.Children.Contains( roof.Id ) ) ) {
if ( !owned.Contains( plan.Buildings.FirstOrDefault( building => building.Roofs.Contains( roof ) ) ) ) {
yield return roof;
}
}
foreach ( var road in plan.Roads().Where( road => group.Children.Contains( road.Id ) ) ) {
yield return road;
}
}
static bool Bounds( ArchPlan plan, IEnumerable<object> members, out Vector2 min, out Vector2 max ) {
var low = new Vector2( float.MaxValue, float.MaxValue );
var high = new Vector2( float.MinValue, float.MinValue );
var found = false;
foreach ( var member in members ) {
var loops = member switch {
ArchBuilding building => building.Rooms.Select( ArchFloorGen.Footprint ),
ArchRoom room => new[] { ArchFloorGen.Footprint( room ) }.AsEnumerable(),
ArchRoofPart roof => new[] { roof.Outline() }.AsEnumerable(),
ArchRoadPart road => new[] { road.Nodes.Select( node => new Vector2( node.Position.x, node.Position.y ) ).ToList() }.AsEnumerable(),
_ => Enumerable.Empty<List<Vector2>>()
};
foreach ( var loop in loops.Where( loop => loop.Count >= 2 ) ) {
ArchFootprint.Bounds( loop, out var loopMin, out var loopMax );
low = Vector2.Min( low, loopMin );
high = Vector2.Max( high, loopMax );
found = true;
}
}
min = low;
max = high;
return found;
}
static bool Room( ArchTool tool, ArchRoom room ) {
var outline = ArchFloorGen.Footprint( room );
if ( outline.Count < 3 ) {
return false;
}
var building = tool.Plan.OwnerOf( room );
var gesture = ArchGesture.On( ArchKind.Room, room.Id );
var height = room.BaseHeight;
if ( ArchShapeHandles.Corners( tool, gesture, outline, height, Carried( building ) )
| ArchShapeHandles.Edges( tool, gesture, outline, height, CarriedAlong( building ) ) ) {
return true;
}
var centre = Middle( outline );
if ( !ArchShapeHandles.Move( tool, gesture.At( "body" ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
return false;
}
ArchCarry.Shift( room, moved - centre );
return true;
}
static bool Wall( ArchTool tool, ArchWall wall, ArchRoom room ) {
var building = room is null ? null : tool.Plan.OwnerOf( room );
var floor = room?.BaseHeight ?? tool.LevelHeight;
var gesture = ArchGesture.On( ArchKind.Wall, wall.Id );
var middle = (wall.Start + wall.End) * 0.5f;
if ( Slid( tool, gesture, building, wall, floor, middle ) ) {
return true;
}
var changed = End( tool, gesture.At( "start" ), building, floor, wall.Start, corner => wall.Start = corner );
changed |= End( tool, gesture.At( "end" ), building, floor, wall.End, corner => wall.End = corner );
var standing = ArchWallSection.Height( wall, room, tool.Kit );
if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), middle, floor + standing, out var head, Gizmo.Colors.Roll ) ) {
wall.Height = MathF.Max( 1f, head - floor );
changed = true;
}
return changed;
}
// Both ends shift by the same delta to preserve wall length and angle.
static bool Slid( ArchTool tool, ArchGesture gesture, ArchBuilding building, ArchWall wall, float floor, Vector2 middle ) {
if ( !ArchShapeHandles.Position( tool, gesture.At( "body" ), new Vector3( middle.x, middle.y, floor ), out var moved ) ) {
return false;
}
var shift = new Vector2( moved.x, moved.y ) - middle;
if ( shift.Length < 0.01f ) {
return false;
}
var from = wall.Start;
var to = wall.End;
wall.Start = from + shift;
wall.End = to + shift;
ArchCarry.Corner( building, from, wall.Start );
ArchCarry.Corner( building, to, wall.End );
return true;
}
// Landed (delta-snapped), not absolute-snapped — off-grid corners would jump on grab.
static bool End( ArchTool tool, ArchGesture gesture, ArchBuilding building, float floor, Vector2 corner, Action<Vector2> place ) {
if ( !ArchShapeHandles.Landed( tool, gesture, new Vector3( corner.x, corner.y, floor ), out var moved ) ) {
return false;
}
place( moved );
ArchCarry.Corner( building, corner, moved );
return true;
}
static bool Opening( ArchTool tool, ArchOpening opening, ArchRoom room ) {
if ( room?.Walls.FirstOrDefault( candidate => candidate.Openings.Contains( opening ) ) is not { } wall ) {
return false;
}
var floor = room.BaseHeight;
var gesture = ArchGesture.On( ArchKind.Opening, opening.Id );
var changed = false;
foreach ( var edge in new[] { false, true } ) {
var along = edge ? opening.Right : opening.Left;
var at = wall.PointAt( Math.Clamp( along, 0f, wall.Length ) );
if ( !ArchShapeHandles.Reach( tool, gesture.At( edge ? "right" : "left" ),
new Vector3( at.x, at.y, floor + opening.SillHeight ), wall.Start, wall.Direction, out var pulled ) ) {
continue;
}
var other = edge ? opening.Left : opening.Right;
opening.Width = MathF.Max( 4f, MathF.Abs( pulled - other ) );
opening.Offset = (pulled + other) * 0.5f;
changed = true;
}
var centre = wall.PointAt( Math.Clamp( opening.Offset, 0f, wall.Length ) );
if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), centre, floor + opening.Top, out var head, Gizmo.Colors.Roll ) ) {
opening.Height = MathF.Max( 4f, head - floor - opening.SillHeight );
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "sill" ), centre, floor + opening.SillHeight, out var sill, Gizmo.Colors.Pitch ) ) {
var top = opening.Top;
opening.SillHeight = MathF.Max( 0f, MathF.Min( sill - floor, top - 4f ) );
opening.Height = MathF.Max( 4f, top - opening.SillHeight );
changed = true;
}
return changed;
}
static bool Modifier( ArchTool tool, ArchWallModPart modifier, ArchRoom room ) {
if ( room?.Walls.FirstOrDefault( candidate => candidate.Modifiers.Contains( modifier ) ) is not { } wall ) {
return false;
}
var floor = room.BaseHeight;
var standing = ArchWallSection.Height( wall, room, tool.Kit );
var top = modifier.Height > 0.5f ? modifier.Base + modifier.Height : standing;
var gesture = ArchGesture.On( ArchKind.WallMod, modifier.Id );
var changed = false;
foreach ( var edge in new[] { false, true } ) {
var along = edge ? modifier.Right : modifier.Left;
var at = wall.PointAt( Math.Clamp( along, 0f, wall.Length ) );
if ( !ArchShapeHandles.Reach( tool, gesture.At( edge ? "right" : "left" ),
new Vector3( at.x, at.y, floor + modifier.Base ), wall.Start, wall.Direction, out var pulled ) ) {
continue;
}
var other = edge ? modifier.Left : modifier.Right;
modifier.Width = MathF.Max( 2f, MathF.Abs( pulled - other ) );
modifier.Along = (pulled + other) * 0.5f;
changed = true;
}
var centre = wall.PointAt( Math.Clamp( modifier.Along, 0f, wall.Length ) );
if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), centre, floor + top, out var head, Gizmo.Colors.Roll ) ) {
var reach = head - floor - modifier.Base;
// Height 0 means "climb to wall head" — landing near the head resets to that sentinel.
var atHead = MathF.Abs( reach - standing ) < ArchGridService.FinestSize;
modifier.Height = atHead ? 0f : MathF.Max( 2f, reach );
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "foot" ), centre, floor + modifier.Base, out var foot, Gizmo.Colors.Pitch ) ) {
modifier.Base = Math.Clamp( foot - floor, 0f, top - 2f );
modifier.Height = MathF.Max( 2f, top - modifier.Base );
changed = true;
}
return changed;
}
// Following trims only offer a lift handle; hand-drawn trims get per-node handles.
static bool Trim( ArchTool tool, ArchTrimPart trim ) {
if ( trim.Path.Count == 0 ) {
return false;
}
var gesture = ArchGesture.On( ArchKind.Trim, trim.Id );
if ( !trim.Follows ) {
return Points( tool, gesture, trim.Path );
}
var centre = trim.Path.Aggregate( Vector3.Zero, ( sum, point ) => sum + point ) / trim.Path.Count;
if ( !ArchShapeHandles.Height( tool, gesture.At( "lift" ), centre, centre.z, out var lifted, Gizmo.Colors.Roll ) ) {
return false;
}
trim.Lift += lifted - centre.z;
ArchTrimAffector.Refollow( tool.Plan, trim );
return true;
}
static bool Points( ArchTool tool, ArchGesture gesture, List<Vector3> path ) {
var changed = false;
for ( var index = 0; index < path.Count; index++ ) {
if ( !ArchShapeHandles.Control( tool, gesture.At( "point", index ), path[index], out var moved ) ) {
continue;
}
path[index] = moved;
changed = true;
}
return changed;
}
static bool Downpipe( ArchTool tool, ArchDownpipePart pipe ) {
var gesture = ArchGesture.On( ArchKind.Downpipe, pipe.Id );
var changed = false;
if ( ArchShapeHandles.Move( tool, gesture, pipe.Outlet, out var moved ) ) {
pipe.Outlet = moved;
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "top" ), pipe.Outlet, pipe.TopHeight, out var top, Gizmo.Colors.Roll ) ) {
pipe.TopHeight = MathF.Max( pipe.BaseHeight + 1f, top );
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "shoe" ), pipe.Wall, pipe.BaseHeight, out var foot, Gizmo.Colors.Pitch ) ) {
pipe.BaseHeight = MathF.Min( pipe.TopHeight - 1f, foot );
changed = true;
}
return changed;
}
static bool Ladder( ArchTool tool, ArchLadderPart ladder ) {
var gesture = ArchGesture.On( ArchKind.Ladder, ladder.Id );
var changed = false;
if ( ArchShapeHandles.Move( tool, gesture, ladder.Anchor, out var moved ) ) {
ladder.Anchor = moved;
changed = true;
}
if ( ArchShapeHandles.Height( tool, gesture.At( "foot" ), ladder.Anchor, ladder.BaseHeight, out var foot, Gizmo.Colors.Pitch ) ) {
ladder.BaseHeight = MathF.Min( ladder.TopHeight - 1f, foot );
changed = true;
}
return changed;
}
static bool Balcony( ArchTool tool, ArchBalconyPart balcony ) {
var gesture = ArchGesture.On( ArchKind.Balcony, balcony.Id );
var facing = balcony.Facing;
var along = balcony.Along;
var half = MathF.Max( 6f, balcony.Width * 0.5f );
var rail = balcony.RailHeight > 1f ? balcony.RailHeight : tool.Kit.BalconyRail;
var depth = balcony.IsJuliet ? ArchLap.Proud( tool.Kit ) : balcony.Reach;
if ( ArchShapeHandles.Move( tool, gesture, balcony.Anchor, out var moved ) ) {
balcony.Anchor = moved;
return true;
}
ArchFootprint.Bounds( new List<Vector2>
{
balcony.Anchor - along * half,
balcony.Anchor + along * half,
balcony.Anchor - along * half + facing * depth,
balcony.Anchor + along * half + facing * depth
}, out var min, out var max );
if ( !ArchShapeHandles.Volume( tool, gesture.At( "volume" ), min, max, balcony.BaseHeight, balcony.BaseHeight + rail, out var pulled ) ) {
return false;
}
var span = new Vector2( pulled.Maxs.x - pulled.Mins.x, pulled.Maxs.y - pulled.Mins.y );
balcony.Width = ArchGridService.Least( Extent( span, along ), 12f );
balcony.BaseHeight = pulled.Mins.z;
balcony.RailHeight = MathF.Max( 12f, pulled.Maxs.z - pulled.Mins.z );
if ( !balcony.IsJuliet ) {
balcony.Reach = MathF.Sign( balcony.Reach ) * ArchGridService.Least( Extent( span, facing ), 4f );
}
return true;
}
static float Extent( Vector2 span, Vector2 axis ) => MathF.Abs( axis.x ) * span.x + MathF.Abs( axis.y ) * span.y;
static bool Approach( ArchTool tool, ArchApproachPart approach ) {
var gesture = ArchGesture.On( ArchKind.Approach, approach.Id );
var outward = ArchApproachAxes.Outward( approach );
var across = ArchApproachAxes.Across( approach );
var run = MathF.Max( 12f, approach.Run );
if ( ArchShapeHandles.Move( tool, gesture, approach.Origin, out var moved ) ) {
approach.Origin = moved;
return true;
}
if ( ArchShapeHandles.Reach( tool, gesture.At( "run" ),
Standing( tool, approach.Origin + outward * run ),
approach.Origin, outward, out var reached ) ) {
approach.Run = MathF.Max( 12f, reached );
return true;
}
var middle = approach.Origin + outward * run * 0.5f;
if ( !ArchShapeHandles.Width( tool, gesture.At( "width" ),
Standing( tool, middle + across * ArchApproachAxes.HalfWidth( approach ) ),
middle, across, 12f, out var width ) ) {
return false;
}
approach.Width = width;
return true;
}
static Vector3 Standing( ArchTool tool, Vector2 at ) => new( at.x, at.y, tool.LevelHeight );
static Vector2 Middle( IReadOnlyList<Vector2> outline ) {
if ( outline is not { Count: > 0 } ) {
return Vector2.Zero;
}
ArchFootprint.Bounds( outline, out var min, out var max );
return (min + max) * 0.5f;
}
}