Editor helper for placing wall-mounted fixtures. Finds the nearest wall to a cursor or drag, chooses an outward facing side (preferring exterior faces), snaps placement to neighbouring stations or grid, and returns an ArchFixtureStation with wall, building, room, along, width and outward direction.
using System;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
// Which wall a wall-hung fixture takes, and which way it faces. One answer for the ladder, the balcony and the
// fire escape, because all three ask the same question and three copies of it gave three answers.
//
// An exterior wall wins OUTRIGHT over a nearer partition: a thing whose whole purpose is to hang outside has no
// business on a wall with rooms both sides, however close the click landed to it. And its face comes from the
// side the building does not occupy, never from which side of the centreline the snapped cursor fell on - at a
// few inches out that is a coin toss, which is how a fire escape and a balcony both came out indoors.
//
// A free-standing wall has no outside to probe, so there the click is the only thing left that can say which
// face was meant, and it decides.
// Where along that wall the fixture stands and how wide it is - resolved ONCE, so the hover ghost, the drag
// preview and the placement cannot disagree about the unit they are each describing.
public readonly record struct ArchFixtureStation(
ArchWall Wall,
ArchBuilding Building,
ArchRoom Room,
Vector2 Outward,
float Along,
float Width,
bool Dragged )
{
public Vector2 Centre => Wall.PointAt( Along );
}
public static class ArchFixtureAnchor
{
public const float Reach = 240f;
public const float Least = 12f;
public static ArchWall Nearest(
ArchPlan plan,
IEnumerable<ArchBuilding> buildings,
int? level,
Vector2 point,
out ArchBuilding host,
out ArchRoom room,
out float along,
out Vector2 outward )
{
host = null;
room = null;
along = 0f;
outward = default;
if ( buildings is null )
{
return null;
}
var kinds = ArchKinds.Load();
var found = new Candidate();
var loose = new Candidate();
foreach ( var building in buildings )
{
foreach ( var candidateRoom in building.Rooms )
{
if ( level is { } storey && candidateRoom.Floor != storey )
{
continue;
}
foreach ( var wall in plan.Filed<ArchWall>( candidateRoom, kinds ) )
{
Consider( building, candidateRoom, wall, point, ref found, ref loose );
}
}
}
var best = found.Wall is not null ? found : loose;
if ( best.Wall is null || best.Gap > Reach )
{
return null;
}
host = best.Building;
room = best.Room;
along = best.Along;
outward = best.Outward;
return best.Wall;
}
// A drag ALONG the elevation names the station and the width in one gesture, the way every other placement
// tool works. Both ends are clamped into the wall's run BEFORE a width is taken from them - measuring the
// width first and centring it after is what left an uneven sliver at each end of a full-length unit, which
// is the same trap opening placement already fell into once.
//
// A drag too short to have been meant as one is a CLICK, and a click means the typed width centred where it
// landed - the same bargain the bool tool makes for a box smaller than a cell.
public static ArchFixtureStation? Station(
IEnumerable<ArchBuilding> buildings,
int? level,
ArchGridService grid,
Vector2 from,
Vector2 to,
float typedWidth,
ArchPlan plan = null )
{
var wall = Nearest( plan, buildings, level, (from + to) * 0.5f, out var host, out var room, out _, out var outward );
if ( wall is null || room is null )
{
return null;
}
var run = wall.Length;
var usable = MathF.Max( Least, run );
var start = Math.Clamp( Vector2.Dot( from - wall.Start, wall.Direction ), 0f, run );
var finish = Math.Clamp( Vector2.Dot( to - wall.Start, wall.Direction ), 0f, run );
if ( finish < start )
{
(start, finish) = (finish, start);
}
var dragged = finish - start >= grid.SubgridSize();
var width = dragged
? finish - start
: Math.Clamp( typedWidth > Least ? typedWidth : Least, Least, usable );
if ( dragged )
{
start = Snapped( wall, grid, start );
finish = Snapped( wall, grid, finish );
width = MathF.Max( Least, finish - start );
}
else
{
width = MathF.Max( Least, grid.Subgrid( width, 4 ) );
start = Math.Clamp( Vector2.Dot( from - wall.Start, wall.Direction ) - width * 0.5f, 0f, MathF.Max( 0f, run - width ) );
}
width = MathF.Min( width, usable );
var along = Math.Clamp( start + width * 0.5f, width * 0.5f, MathF.Max( width * 0.5f, run - width * 0.5f ) );
return new ArchFixtureStation( wall, host, room, outward, along, width, dragged );
}
// The click form: one point, and the width comes from what was typed.
public static ArchFixtureStation? At( IEnumerable<ArchBuilding> buildings, int? level, ArchGridService grid, Vector2 point, float typedWidth, ArchPlan plan = null )
{
return Station( buildings, level, grid, point, point, typedWidth, plan );
}
// Onto whatever is already standing on this elevation first - an opening's jamb or a fixture's edge - and
// onto the grid otherwise. Snapping to neighbours is the whole reason a run of balconies lines up.
static float Snapped( ArchWall wall, ArchGridService grid, float along )
{
var reach = grid.SubgridSize();
var best = float.MaxValue;
var landed = along;
foreach ( var station in Neighbours( wall ) )
{
var gap = MathF.Abs( station - along );
if ( gap < reach && gap < best )
{
best = gap;
landed = station;
}
}
return best < float.MaxValue ? landed : grid.Subgrid( along, 4 );
}
static IEnumerable<float> Neighbours( ArchWall wall )
{
foreach ( var opening in wall.Openings )
{
var half = opening.Width * 0.5f;
yield return opening.Offset - half;
yield return opening.Offset + half;
}
}
static void Consider( ArchBuilding building, ArchRoom room, ArchWall wall, Vector2 point, ref Candidate found, ref Candidate loose )
{
var length = wall.Length;
if ( length < 0.5f )
{
return;
}
var along = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, length );
var gap = (point - wall.PointAt( along )).Length;
if ( ArchWallFaces.TryOutward( wall, room, building, out var outward ) )
{
found.Take( building, room, wall, along, gap, outward );
return;
}
loose.Take( building, room, wall, along, gap, Clicked( wall, along, point ) );
}
// The wall's own normal, turned to the side the click came from - the only answer available where the
// building cannot say which side is outside.
static Vector2 Clicked( ArchWall wall, float along, Vector2 point )
{
var normal = wall.Normal;
return Vector2.Dot( normal, point - wall.PointAt( along ) ) < 0f ? -normal : normal;
}
struct Candidate
{
public ArchBuilding Building;
public ArchRoom Room;
public ArchWall Wall;
public float Along;
public float Gap;
public Vector2 Outward;
public void Take( ArchBuilding building, ArchRoom room, ArchWall wall, float along, float gap, Vector2 outward )
{
if ( Wall is not null && gap >= Gap )
{
return;
}
Building = building;
Room = room;
Wall = wall;
Along = along;
Gap = gap;
Outward = outward;
}
}
}