Editor UI helper for architecture shapes. Provides gizmo/handle logic for move, turn and scale modes and implements many handle types (volume, square, position, lift, turn, control, width, push, edges, corners, nodes, etc.), snapping and accumulated drag history.
using System;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
// Which widget a selected shape wears. ONE at a time, the way every editor does it: a mover, a ring and a
// six-faced box all standing on the same shape at once is three sets of arrows you have to aim between.
public enum ArchHandleMode
{
Move,
Turn,
Scale
}
// Squares, not translate gizmos: eight three-axis widgets on one footprint is a thicket.
public static class ArchShapeHandles
{
// Remembered across tools and sessions, like the engine's own gizmo mode - it is a way of working, not a
// property of whatever happens to be picked.
public static ArchHandleMode Mode
{
get => (ArchHandleMode)EditorCookie.Get( "arch.handles.mode", (int)ArchHandleMode.Scale );
set => EditorCookie.Set( "arch.handles.mode", (int)value );
}
// Wider than the engine's own 19, which is sized to nest inside a pitch and a roll ring this one has not got.
const float RingSize = 34f;
// Drags accumulate off-grid; snapping the per-frame delta rounds to nothing.
static readonly Dictionary<string, Vector3> loose = new();
// Where a mover was grabbed, so an axis the drag never carried can be left exactly where it stood.
static readonly Dictionary<string, Vector3> grabbed = new();
// The angle a ring has been carried to since it was grabbed. Without it the control is handed the same
// value every frame, hands back the same movement every frame, and the shape spins away.
static readonly Dictionary<string, float> carried = new();
public static bool HandlePressed { get; private set; }
// ANY gizmo being held counts, not just one whose own scope reports the press: an arrow of the engine's
// mover is a child scope, so asking only about the parent let the click through to place a second shape
// behind the widget that was clicked. The button has to still be DOWN though: the engine keeps its pressed
// path alive through the release frame, so a widget that was never touched still claimed that release -
// and the release a placement drag ends on is the one that places the shape.
public static void BeginFrame()
{
HandlePressed = Gizmo.Pressed.Any && Gizmo.IsLeftMouseDown;
// Accumulated travel belongs to ONE gesture, and a frame the widget reports no movement on is still
// that same gesture - forgetting it there threw away every remainder under a whole rung, so a slow
// drag never advanced at all. With the button up there is no gesture left to remember.
if ( Gizmo.IsLeftMouseDown )
{
return;
}
loose.Clear();
grabbed.Clear();
carried.Clear();
}
public static bool CapturesPlacement( bool handlePressed, bool adjusting ) => handlePressed || adjusting;
// The other half of the same question: WHETHER a picked shape has widgets at all. The one just drawn does
// not, until a gesture has been and gone - its box dragger would stand over the surface the next shape gets
// drawn on, and the gizmo takes the press first.
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;
// Every handle lands through here, because a handle EDITS: the shape keeps where it stands and the drag
// moves it by whole rungs. Snapping the coordinate instead is what made a widget jump the moment it was
// grabbed - 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;
}
// Drag plane is the plane you're looking at - never an unseen axis.
public static Rotation Facing( ArchViewAxis axis ) => axis switch
{
ArchViewAxis.Front => Rotation.LookAt( Vector3.Forward ),
ArchViewAxis.Side => Rotation.LookAt( Vector3.Left ),
_ => Rotation.LookAt( Vector3.Up )
};
// The engine's own six-faced box dragger, which is the right widget for anything that IS a box: one
// arrow per face, height included, instead of a corner square per vertex and two lonely height pins.
// It already accumulates its own loose motion and snaps on write, so the drag stays smooth - but it
// snaps on the VIEWPORT's ladder, so the result is put back through ours before it reaches the plan.
//
// The frame is for a part that does not stand on the world axes: it takes coordinates with the yaw already
// out of them and hands them back the same way, because bounds measured across the world squared a turned
// shape off the instant one of its faces was pulled.
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;
// Each face relative to where THAT face stood: a turned shape's bounds are nowhere near a rung, so
// snapping them outright squared the box off the instant any one of its six arrows was pulled.
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;
}
// The raw square, and the ONE thing in the tool that hands back accumulated travel. Private, because a
// caller holding raw travel is a caller that has to remember its own snap, and nineteen of them each
// remembering a slightly different one is how a widget comes to land somewhere its own shape does not.
// Everything outside asks for the LANDED value it actually wanted - a point, a distance, a station.
static bool Square( ArchTool tool, ArchGesture gesture, Vector3 at, out Vector3 moved, Color? tint = null )
{
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 ), 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;
}
// A plan point moved by whole rungs from where it stood, which is what an EDIT is: a corner authored off
// the ladder keeps its offset instead of jumping onto the nearest rung the moment it is grabbed.
public static bool Landed( 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 = Stepped( tool, moved, new Vector2( dragged.x, dragged.y ) );
return true;
}
// A plan point put down ON the ladder rather than stepped along it - for a shape whose ends are authored
// as coordinates, where the offset a step preserves is an offset nobody meant.
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;
}
// A curve control, which steps in three dimensions because the run it shapes climbs. Stepped like every other
// handle, height included: a node draped on ground stands nowhere near a rung, so snapping it outright dropped
// a whole road back into the terrain the moment one of its nodes was nudged sideways.
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 );
// A wall standing on this surface takes the point ahead of the ladder, which is what makes a flight
// dragged against plaster land ON it rather than a rung short of it.
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;
}
// How far along an axis the drag reached, measured from a fixed origin - an opening's station on its wall,
// an approach's run out of its building. The finest ladder, because these are dimensions rather than places.
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;
}
// A span widened from its MIDDLE, so both edges grow together: the drag reaches half of it and the width
// lands on the ladder whole. Dragged from the side, because a width is judged against what stands beside it.
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 = MathF.Max( smallest, ArchGridService.Fine( MathF.Abs( reach ) * 2f ) );
return true;
}
// A stepped delta off a station along one axis, for the dimensions that are read as a PUSH rather than a
// place: an edge normal, a run's open side, the depth of a landing. Snapping the two ends instead squares
// off anything standing at an angle.
public static bool Push( ArchTool tool, ArchGesture gesture, Vector3 at, Vector2 station, Vector2 axis, out float push, Color? tint = null )
{
push = 0f;
if ( !Square( tool, gesture, at, out var dragged, tint ) )
{
return false;
}
push = Stepped( tool, 0f, Vector2.Dot( new Vector2( dragged.x, dragged.y ) - station, axis ) );
return true;
}
// A station slid along a curve, landing wherever the curve carries it - the curve IS the ladder here, and
// rounding the distance as well would fight it.
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;
}
// The same slide read as a snapped DELTA, for a span that travels whole: both its ends move by one figure,
// so the span keeps its length instead of each end rounding its own way.
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;
}
// The optional carry is for a host that cannot be rescaled into new bounds - a shell whose outline is not a
// rectangle - and it is handed the point that moved and where it went, because reading that back off the loop
// afterwards cannot say WHICH corner changed. A carry that reached nothing refuses, so the drag moves nothing
// rather than sliding the widget off the geometry it failed to bring.
public static bool Corners( ArchTool tool, ArchGesture gesture, List<Vector2> outline, float height, Func<Vector2, Vector2, bool> carried = null )
{
var changed = false;
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 ) )
{
continue;
}
if ( carried is not null && !carried( corner, landed ) )
{
continue;
}
outline[index] = landed;
changed = true;
}
return changed;
}
// Pushed along its normal. The carry is handed the whole EDGE and the push, because a party wall meets an
// exterior run anywhere along it: carrying only the two endpoints tears every T-junction off the wall line.
public static bool Edges( ArchTool tool, ArchGesture gesture, List<Vector2> outline, float height, Func<Vector2, Vector2, Vector2, bool> carried = 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;
if ( !Push( tool, gesture.At( "edge", index ), new Vector3( middle.x, middle.y, height ), middle, normal, out var push, Gizmo.Colors.Green ) )
{
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;
}
// Nodes only: the owner already draws the curve; tangent handles would read as two curves.
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;
}
// The climb, grabbable from ANY camera. Height is a square, and a square is what could only ever be offered in
// an elevation - every other view aims its drag axis straight at the camera. An arrow does not care: it is
// dragged along its own axis, which is why the engine's own mover works in the perspective viewport. So the
// elevation-only clause was never a law about height, only about squares, and this is what a stair's rise
// hangs on - one flight of a mansion stair pinned three risers up and the next taking the rest.
//
// Arrow hands back a PER-FRAME delta, not where it has been carried to, and its own snapSize rounds only the
// line it draws - so the travel is accumulated and stepped here exactly as Square's is. Trusting the control's
// own snap would round every frame's delta to nothing and the arrow would never move at all.
//
// It culls itself when the axis points within ten degrees of the camera, so straight down in a Top view it
// stands aside. That is correct - there is no drag to make - and it is why the elevation's square stays.
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;
}
// Only in an elevation: in the plan the drag axis points at the camera.
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;
}
// The engine's OWN three-arrow mover, so a boolean travels the way every other thing in the editor
// travels - and along one axis at a time, which is the only way an author can say which axis they meant.
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;
// The control draws at its SCOPE and hands back a per-frame delta: given world coordinates under an
// identity scope it stands at the origin, so the position goes into the scope and zero into the control.
//
// ARROWS ONLY - the squares are asked for at zero size. The engine's mover carries a camera-plane
// square in the middle and a quad on each pair of axes, and in any view that is not straight down
// those drag through Z: sliding a shape sideways lifted it half a rung, which the ladder then rounded
// into a whole one. An arrow is one axis, so the height moves when the height arrow is pulled.
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;
}
// Measured from where it was GRABBED, so the travel lands on the ladder and the place it started does not.
// Only an axis the drag actually carried moves at all: an untouched one comes home as a rounding crumb.
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 );
}
// An arrow hands back its movement rotated, so an untouched axis comes home as a rounding crumb rather
// than a clean zero.
static bool Carried( float travelled, float from ) => MathF.Abs( travelled - from ) > ArchGridService.FinestSize;
// The YAW ring alone, read as the angle it moved THIS FRAME. A footprint is a loop on the plan and can only
// swing about Z, so the engine's full three-ring control offered a pitch and a roll that meant nothing -
// and past ninety degrees either one decomposed into a yaw of a hundred and eighty, which read as the shape
// flipping for a gesture that never turned it.
//
// A delta rather than an absolute, because a loop has no angle field to read back - it IS the shape. The
// ring measures from where it was GRABBED, so the total it reports is turned back into an increment here:
// hand the caller the total instead and a still mouse turns the shape forever.
//
// A step ratchets that total, and it has to be done HERE rather than by the caller: the caller sees one
// frame's increment, so rounding it to fifteen degrees throws away every frame that moved less and the
// shape never turns at all. What was handed over is remembered, so the remainder keeps accumulating - and
// the ratchet counts from where the ring was grabbed, never from the angle the shape already stood at.
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 ) ) )
{
// A WHOLE ring, and a wide one. The engine draws half of each ring so three of them stacked can be
// told apart; there is only this one, so the half that was hidden was simply a ring you could not
// find - and it was drawn at the radius meant to nest inside two others.
var moved = Gizmo.Control.RotateSingle( "turn", Gizmo.Colors.Yaw, out var spun, RingSize, false );
HandlePressed |= Gizmo.Pressed.This;
if ( !moved )
{
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;
}
// The increment every authored angle lands on, so a turned shape still meets what it stands against.
public static float Facing( float degrees ) => MathF.Round( degrees / ArchGridService.AngleStep ) * ArchGridService.AngleStep;
// Into -180..180: crossing the wrap reads as a 359 degree swing back the other way.
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 );
}
}