Editor/Handles/ArchShapeHandles.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
public enum ArchHandleMode {
Move,
Turn,
Scale
}
public static class ArchShapeHandles {
// Persisted across tools and sessions via EditorCookie.
public static ArchHandleMode Mode {
get => (ArchHandleMode)EditorCookie.Get( "arch.handles.mode", (int)ArchHandleMode.Scale );
set => EditorCookie.Set( "arch.handles.mode", (int)value );
}
const float RingSize = 34f;
static readonly Dictionary<string, Vector3> loose = new();
static readonly Dictionary<string, Vector3> grabbed = new();
static readonly Dictionary<string, float> carried = new();
public static bool HandlePressed { get; private set; }
// Pressed.Any includes child scopes; require mouse down to exclude the release frame.
public static void BeginFrame() {
HandlePressed = Gizmo.Pressed.Any && Gizmo.IsLeftMouseDown;
if ( Gizmo.IsLeftMouseDown ) {
return;
}
loose.Clear();
grabbed.Clear();
carried.Clear();
}
public static bool CapturesPlacement( bool handlePressed, bool adjusting ) => handlePressed || adjusting;
// Suppress handles on the shape just drawn so its dragger doesn't capture the next placement.
public static bool ShowsHandles( object picked, object drawn ) => picked is not null && !ReferenceEquals( picked, drawn );
public static bool Snapping( bool snapToGrid, bool controlHeld ) => snapToGrid != controlHeld;
// Steps a delta, not the coordinate — see ArchGridService.Stepped.
static Vector2 Stepped( ArchTool tool, Vector2 from, Vector2 to ) {
return Snapping( Gizmo.Settings.SnapToGrid, Gizmo.IsCtrlPressed )
? ArchGridService.Stepped( from, to, tool.Grid.BaseSize )
: to;
}
static float Stepped( ArchTool tool, float from, float to ) {
return Snapping( Gizmo.Settings.SnapToGrid, Gizmo.IsCtrlPressed )
? ArchGridService.Stepped( from, to, tool.Grid.BaseSize )
: to;
}
public static Rotation Facing( ArchViewAxis axis ) => Facing( axis, 0f );
// Yaw only applies in plan view — in elevation it would tilt the drag plane.
public static Rotation Facing( ArchViewAxis axis, float yaw ) => axis switch {
ArchViewAxis.Front => Rotation.LookAt( Vector3.Forward ),
ArchViewAxis.Side => Rotation.LookAt( Vector3.Left ),
_ => Rotation.FromYaw( yaw ) * Rotation.LookAt( Vector3.Up )
};
// Optional frame rotates into local space so turned shapes don't get axis-aligned.
public static bool Volume( ArchTool tool, ArchGesture gesture, Vector2 min, Vector2 max, float bottom, float top, out BBox pulled, Rotation? frame = null ) {
var standing = new BBox( new Vector3( min.x, min.y, bottom ), new Vector3( max.x, max.y, top ) );
pulled = standing;
if ( Mode != ArchHandleMode.Scale ) {
return false;
}
using ( Gizmo.Scope( gesture.Key, new Transform( Vector3.Zero, frame ?? Rotation.Identity ) ) ) {
if ( !Gizmo.Control.BoundingBox( "volume", standing, out var moved ) ) {
HandlePressed |= Gizmo.Pressed.This;
return false;
}
HandlePressed |= Gizmo.Pressed.This;
// Step each face relative to where it stood, not absolutely.
var low = Stepped( tool, new Vector2( standing.Mins.x, standing.Mins.y ), new Vector2( moved.Mins.x, moved.Mins.y ) );
var high = Stepped( tool, new Vector2( standing.Maxs.x, standing.Maxs.y ), new Vector2( moved.Maxs.x, moved.Maxs.y ) );
var foot = Stepped( tool, standing.Mins.z, MathF.Min( moved.Mins.z, moved.Maxs.z ) );
var head = Stepped( tool, standing.Maxs.z, MathF.Max( moved.Mins.z, moved.Maxs.z ) );
pulled = new BBox(
new Vector3( MathF.Min( low.x, high.x ), MathF.Min( low.y, high.y ), MathF.Min( foot, head ) ),
new Vector3( MathF.Max( low.x, high.x ), MathF.Max( low.y, high.y ), MathF.Max( foot, head ) ) );
}
return (pulled.Mins - standing.Mins).Length > 0.01f || (pulled.Maxs - standing.Maxs).Length > 0.01f;
}
// Accumulates raw travel; callers use the public wrappers that snap to the appropriate ladder.
static bool Square( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector3 moved, Color? tint = null, float yaw = 0f ) {
moved = at;
var size = MathF.Max( 3f, tool.Kit.GridSize * 0.25f );
var movement = Vector3.Zero;
var held = false;
var key = gesture.Key;
using ( Gizmo.Scope( key, new Transform( at ) ) ) {
Gizmo.Draw.Color = tint ?? Gizmo.Colors.Active;
held = Gizmo.Control.DragSquare( "drag", new Vector2( size, size ), Facing( tool.Axis, yaw ), out movement );
HandlePressed |= Gizmo.Pressed.This;
}
if ( !held ) {
return false;
}
var travelled = (loose.TryGetValue( key, out var carried ) ? carried : at) + movement;
loose[key] = travelled;
moved = travelled;
return true;
}
public static bool Landed( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector2 moved, Color? tint = null, float yaw = 0f ) {
moved = new Vector2( at.x, at.y );
if ( !Square( tool, gesture, at, out var dragged, tint, yaw ) ) {
return false;
}
moved = Stepped( tool, moved, new Vector2( dragged.x, dragged.y ) );
return true;
}
// Snaps to grid absolutely, unlike Landed which preserves off-grid offset.
public static bool Placed( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector2 moved, Color? tint = null ) {
moved = new Vector2( at.x, at.y );
if ( !Square( tool, gesture, at, out var dragged, tint ) ) {
return false;
}
moved = tool.Snap( new Vector2( dragged.x, dragged.y ) );
return true;
}
public static bool Control( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector3 moved, Color? tint = null ) {
moved = at;
if ( !Square( tool, gesture, at, out var dragged, tint ) ) {
return false;
}
var free = new Vector2( dragged.x, dragged.y );
// Snap to wall surface if nearby, otherwise step normally.
var flat = tool.Walled( free, out var wall ) ? wall : Stepped( tool, new Vector2( at.x, at.y ), free );
moved = new Vector3( flat.x, flat.y, Stepped( tool, at.z, dragged.z ) );
return true;
}
public static bool Reach( ArchTool tool, ArchGesture gesture, Vector3 at, Vector2 origin, Vector2 direction, out float distance, Color? tint = null ) {
distance = 0f;
if ( !Square( tool, gesture, at, out var dragged, tint ) ) {
return false;
}
distance = ArchGridService.Fine( Vector2.Dot( new Vector2( dragged.x, dragged.y ) - origin, direction ) );
return true;
}
public static bool Width( ArchTool tool, ArchGesture gesture, Vector3 at, Vector2 middle, Vector2 across, float smallest, out float width, Color? tint = null ) {
width = smallest;
if ( !Square( tool, gesture, at, out var dragged, tint ) ) {
return false;
}
var reach = Vector2.Dot( new Vector2( dragged.x, dragged.y ) - middle, across );
width = ArchGridService.Least( MathF.Abs( reach ) * 2f, smallest );
return true;
}
public static bool Push( ArchTool tool, ArchGesture gesture, Vector3 at, Vector2 station, Vector2 axis, out float push, Color? tint = null, float yaw = 0f ) {
push = 0f;
if ( !Square( tool, gesture, at, out var dragged, tint, yaw ) ) {
return false;
}
push = Stepped( tool, 0f, Vector2.Dot( new Vector2( dragged.x, dragged.y ) - station, axis ) );
return true;
}
public static bool Station( ArchTool tool, ArchGesture gesture, ArchCurve curve, float at, out float distance, Color? tint = null ) {
distance = at;
if ( !curve.Sample( at, out var seat ) || !Square( tool, gesture, seat.Position, out var dragged, tint ) ) {
return false;
}
if ( !curve.Nearest( new Vector2( dragged.x, dragged.y ), out var landed, out _ ) ) {
return false;
}
distance = landed.Distance;
return true;
}
// Snapped delta along curve — both ends of a span shift by the same amount.
public static bool Slid( ArchTool tool, ArchGesture gesture, ArchCurve curve, float from, out float delta, Color? tint = null ) {
delta = 0f;
if ( !Station( tool, gesture, curve, from, out var distance, tint ) ) {
return false;
}
delta = ArchGridService.Snap( distance - from, tool.Kit.GridSize );
return true;
}
public static bool Corners( ArchTool tool, ArchGesture gesture, List<Vector2> outline, float height, Func<Vector2, Vector2, bool> carried = null ) {
var changed = false;
ArchFootprint.Bearing( outline, out var yaw );
for ( var index = 0; index < outline.Count; index++ ) {
var corner = outline[index];
if ( !Landed( tool, gesture.At( "corner", index ), new Vector3( corner.x, corner.y, height ), out var landed, null, yaw ) ) {
continue;
}
if ( carried is not null && !carried( corner, landed ) ) {
continue;
}
outline[index] = landed;
changed = true;
}
return changed;
}
public static bool Edges( ArchTool tool, ArchGesture gesture, List<Vector2> outline, float height, Func<Vector2, Vector2, Vector2, bool> carried = null, Color? tint = null ) {
var changed = false;
for ( var index = 0; index < outline.Count; index++ ) {
var next = (index + 1) % outline.Count;
var from = outline[index];
var to = outline[next];
var span = to - from;
if ( span.Length < 1f ) {
continue;
}
var middle = (from + to) * 0.5f;
var normal = new Vector2( -span.y, span.x ).Normal;
var yaw = MathF.Atan2( span.y, span.x ).RadianToDegree();
if ( !Push( tool, gesture.At( "edge", index ), new Vector3( middle.x, middle.y, height ), middle, normal, out var push, tint ?? Gizmo.Colors.Green, yaw ) ) {
continue;
}
var reach = normal * push;
if ( carried is not null && !carried( from, to, reach ) ) {
continue;
}
outline[index] = from + reach;
outline[next] = to + reach;
changed = true;
}
return changed;
}
public static bool Nodes( ArchTool tool, ArchGesture gesture, List<ArchCurveNode> nodes ) {
var changed = false;
for ( var index = 0; index < nodes.Count; index++ ) {
var node = nodes[index];
if ( !Control( tool, gesture.At( "node", index ), node.Position, out var moved ) ) {
continue;
}
node.Position = moved;
changed = true;
}
return changed;
}
// Z-axis arrow — works from any camera angle (auto-culls when looking straight down).
public static bool Lift( ArchTool tool, ArchGesture gesture, Vector2 at, float height, out float moved, Color? tint = null ) {
moved = height;
var key = gesture.Key;
var delta = 0f;
var held = false;
using ( Gizmo.Scope( key, new Transform( new Vector3( at.x, at.y, height ) ) ) ) {
Gizmo.Draw.Color = tint ?? Gizmo.Colors.Up;
held = Gizmo.Control.Arrow( "lift", Vector3.Up, out delta );
HandlePressed |= Gizmo.Pressed.This;
}
if ( !held ) {
return false;
}
var travelled = (loose.TryGetValue( key, out var carried ) ? carried.z : height) + delta;
loose[key] = new Vector3( 0f, 0f, travelled );
moved = Stepped( tool, height, travelled );
return true;
}
public static bool Arrow( ArchTool tool, ArchGesture gesture, Vector3 at, Vector2 direction, out Vector2 moved, Color? tint = null )
{
moved = new Vector2( at.x, at.y );
if ( direction.IsNearZeroLength )
{
return false;
}
var axis = direction.Normal;
var key = gesture.Key;
var delta = 0f;
var held = false;
var colour = tint ?? Color.Red;
var flatAxis = new Vector3( axis.x, axis.y, 0f );
using ( Gizmo.Scope( key, new Transform( at ) ) )
{
Gizmo.Draw.Color = colour;
held = Gizmo.Control.Arrow( "arrow", flatAxis, out delta, 46f, 14f, 7f );
Gizmo.Draw.Color = colour;
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( flatAxis * 14f, 14f ) );
HandlePressed |= Gizmo.Pressed.This;
}
if ( !held )
{
return false;
}
var standing = new Vector2( at.x, at.y );
var travelled = (loose.TryGetValue( key, out var carried ) ? new Vector2( carried.x, carried.y ) : standing) + axis * delta;
loose[key] = new Vector3( travelled.x, travelled.y, at.z );
moved = Stepped( tool, standing, travelled );
return true;
}
public static bool Height( ArchTool tool, ArchGesture gesture, Vector2 at, float height, out float moved, Color? tint = null ) {
moved = height;
if ( !tool.InElevation || !Square( tool, gesture, new Vector3( at.x, at.y, height ), out var dragged, tint ) ) {
return false;
}
moved = Stepped( tool, height, dragged.z );
return true;
}
public static bool Position( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector3 moved ) {
moved = at;
if ( Mode != ArchHandleMode.Move ) {
return false;
}
var key = gesture.Key;
// Zero planeSize disables the multi-axis quads that drag through Z in non-top views.
using ( Gizmo.Scope( key, new Transform( at ) ) ) {
var held = Gizmo.Control.Position( "translate", Vector3.Zero, out var dragged, null, 0f );
HandlePressed |= Gizmo.Pressed.This;
if ( !held ) {
return false;
}
var from = grabbed.TryGetValue( key, out var origin ) ? origin : at;
var travelled = (loose.TryGetValue( key, out var carried ) ? carried : at) + dragged;
grabbed[key] = from;
loose[key] = travelled;
moved = Stepped( tool, from, travelled );
}
return (moved - at).Length > 0.01f;
}
static Vector3 Stepped( ArchTool tool, Vector3 from, Vector3 travelled ) {
var flat = Stepped( tool, new Vector2( from.x, from.y ), new Vector2( travelled.x, travelled.y ) );
var lifted = Stepped( tool, from.z, travelled.z );
return new Vector3(
Carried( travelled.x, from.x ) ? flat.x : from.x,
Carried( travelled.y, from.y ) ? flat.y : from.y,
Carried( travelled.z, from.z ) ? lifted : from.z );
}
static bool Carried( float travelled, float from ) => MathF.Abs( travelled - from ) > ArchGridService.FinestSize;
// Yaw-only ring. Returns a per-frame delta (stepped if step > 0), accumulated from grab.
public static bool Turned( ArchGesture gesture, Vector3 at, out float degrees, float step = 0f ) {
degrees = 0f;
if ( Mode != ArchHandleMode.Turn ) {
return false;
}
var key = gesture.Key;
var reported = carried.TryGetValue( key, out var since ) ? since : 0f;
using ( Gizmo.Scope( key, new Transform( at ) ) )
using ( Gizmo.GizmoControls.PushFixedScale() )
using ( Gizmo.Scope( "yaw", Vector3.Zero, Rotation.LookAt( Vector3.Up ) ) ) {
var moved = Gizmo.Control.RotateSingle( "turn", Gizmo.Colors.Yaw, out var spun, RingSize, false );
HandlePressed |= Gizmo.Pressed.This;
// Skip the release frame — the ring reports accumulated total, which would double-apply.
if ( !moved || !Gizmo.IsLeftMouseDown ) {
return false;
}
var reached = step > 0.01f ? ArchGridService.Snap( spun, step ) : spun;
degrees = Wrapped( reached - reported );
if ( MathF.Abs( degrees ) <= 0.001f ) {
return false;
}
carried[key] = reached;
}
return true;
}
public static float Facing( float degrees ) => ArchGridService.Angle( degrees );
static float Wrapped( float degrees ) {
var turn = degrees % 360f;
if ( turn > 180f ) {
return turn - 360f;
}
return turn < -180f ? turn + 360f : turn;
}
public static bool Move( ArchTool tool, ArchGesture gesture, Vector2 at, out Vector2 moved ) {
return Landed( tool, gesture, new Vector3( at.x, at.y, tool.LevelHeight ), out moved, Gizmo.Colors.Blue );
}
}